Split and rename multiple toolkits (#438)

# PR Description
## Split toolkits

This PR splits the `Microsoft`, `Google`, and `Search` toolkits into
multiple toolkits each.
 * `Microsoft` --> `OutlookCalendar`, `OutlookMail`.
* `Google` -----> `GoogleCalendar`, `GoogleContacts`, `GoogleDocs`,
`GoogleDrive`, `Gmail`, `GoogleSheets`
* `Search` -----> `GoogleFinance`, `GoogleFlights`, `GoogleHotels`,
`GoogleJobs`, `GoogleMaps`, `GoogleNews`, `GoogleSearch`,
`GoogleShopping`, `Walmart`, `Youtube`

> The original monolithic toolkits (`Microsoft`, `Google`, `Search`) are
not removed in this PR. The plan is to keep those toolkits around while
we
> 1. Stop documenting the toolkits, 
> 2. Stop displaying the toolkits in the dashboard, and 
> 3. Help customers migrate over to the new split toolkits.

## Rename toolkits
This PR renames the following toolkits 
* `Web` ------------> `Firecrawl`
* `CodeSandbox` ---> `E2B`

> The `Web` and `CodeSandbox` toolkits are not removed in this PR. The
plan is to keep them around while we
> 1. Stop documenting the toolkits, 
> 2. Stop displaying the toolkits in the dashboard, and 
> 3. Help customers migrate over to the new renamed toolkits.

## Rename tools
Since toolkit names were changed, this called for some tools to be
renamed as well.
* `GoogleSearch.SearchGoogle` ----------------> `GoogleSearch.Search`
* `GoogleShopping.SearchShoppingProducts` --->
`GoogleShopping.SearchProducts`
* `Walmart.SearchWalmartProducts` ------------> `Walmart.SearchProducts`
* `Walmart.GetWalmartProductDetails` --------->
`Walmart.GetProductDetails`
* `Youtube.SearchYoutubeVideos` -------------->
`Youtube.SearchForVideos`

## Google File Picker
Improvements to the Google File Picker experience were also added in
this PR.

The following tools will ALWAYS provide llm_instructions in their
response to "let the end-user know that they have the option to select
more files via the file picker url if they want to":
* `GoogleDocs.SearchDocuments`
* `GoogleDocs.SearchAndRetrieveDocuments`
* `GoogleDrive.GetFileTreeStructure`

The following tools will only provide the file picker URL if a 404 or
403 from the Google API:
* `GoogleDocs.InsertTextAtEndOfDocument`
* `GoogleDocs.GetDocumentById`
* `GoogleSheets.GetSpreadsheet`
* `GoogleSheets.WriteToCell`

Also, a standalone `GoogleDrive.GenerateGoogleFilePickerUrl` tool
exists.

## Other
* The `SearchDocuments` and `SearchAndRetrieveDocuments` tools used to
be organized within the Drive portion of the Google toolkit, but I moved
these into the new GoogleDocs toolkit because they are specific to Docs.

# Progress

- [x] `OutlookCalendar`
- [x] `OutlookMail`
- [x] `GoogleFinance`
- [x] `GoogleFlights`
- [x] `GoogleHotels`
- [x] `GoogleJobs`
- [x] `GoogleMaps`
- [x] `GoogleNews`
- [x] `GoogleSearch`
- [x] `GoogleShopping`
- [x] `Walmart`
- [x] `Youtube`
- [x] `GoogleCalendar`
- [x] `GoogleContacts`
- [x] `GoogleDocs`
- [x] `GoogleDrive`
- [x] `Gmail`
- [x] `GoogleSheets`
- [x] `Firecrawl`
- [x] `E2B`
- [x] File picker

# Discussion
* Repeated code is a consequence of splitting toolkits that use the same
provider. I am open to any ideas that would allow multiple toolkits to
reference common code. Comment your ideas in this PR.
This commit is contained in:
Eric Gustin 2025-07-09 16:00:09 -07:00 committed by GitHub
parent a30fc9379a
commit 07c52100f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
290 changed files with 22664 additions and 1 deletions

View file

@ -50,4 +50,13 @@ jobs:
- name: Test toolkit
working-directory: toolkits/${{ matrix.toolkit }}
run: uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml
run: |
# Run pytest and capture exit code
uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml || EXIT_CODE=$?
if [ "${EXIT_CODE:-0}" -eq 5 ]; then
echo "No tests found for toolkit ${{ matrix.toolkit }}, skipping..."
exit 0
elif [ "${EXIT_CODE:-0}" -ne 0 ]; then
exit ${EXIT_CODE}
fi

View file

@ -19,3 +19,23 @@ arcade-stripe
arcade-web
arcade-x
arcade-zoom
arcade-e2b
arcade-firecrawl
arcade-gmail
arcade-google-calendar
arcade-google-contacts
arcade-google-docs
arcade-google-drive
arcade-google-finance
arcade-google-flights
arcade-google-hotels
arcade-google-jobs
arcade-google-maps
arcade-google-news
arcade-google-search
arcade-google-sheets
arcade-google-shopping
arcade-outlook-calendar
arcade-outlook-mail
arcade-walmart
arcade-youtube

View file

@ -0,0 +1,18 @@
files: ^.*/e2b/.*
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

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

@ -0,0 +1,47 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

21
toolkits/e2b/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025, Arcade AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

55
toolkits/e2b/Makefile Normal file
View file

@ -0,0 +1,55 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -0,0 +1,3 @@
from arcade_e2b.tools import create_static_matplotlib_chart, run_code
__all__ = ["create_static_matplotlib_chart", "run_code"]

View file

@ -0,0 +1,10 @@
from enum import Enum
# Models and enums for the e2b code interpreter
class E2BSupportedLanguage(str, Enum):
PYTHON = "python"
JAVASCRIPT = "js"
R = "r"
JAVA = "java"
BASH = "bash"

View file

@ -0,0 +1,4 @@
from arcade_e2b.tools.create_chart import create_static_matplotlib_chart
from arcade_e2b.tools.run_code import run_code
__all__ = ["create_static_matplotlib_chart", "run_code"]

View file

@ -0,0 +1,31 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from e2b_code_interpreter import Sandbox
# See https://e2b.dev/docs to learn more about E2B
# Note: Not recommended to use tool_choice='generate' with this tool
# since it contains base64 encoded image.
@tool(requires_secrets=["E2B_API_KEY"])
def create_static_matplotlib_chart(
context: ToolContext,
code: Annotated[str, "The Python code to run"],
) -> Annotated[dict, "A dictionary with the following keys: base64_image, logs, error"]:
"""
Run the provided Python code to generate a static matplotlib chart.
The resulting chart is returned as a base64 encoded image.
"""
api_key = context.get_secret("E2B_API_KEY")
with Sandbox(api_key=api_key) as sbx:
execution = sbx.run_code(code=code)
result = {
"base64_image": execution.results[0].png if execution.results else None,
"logs": execution.logs.to_json(),
"error": execution.error.to_json() if execution.error else None,
}
return result

View file

@ -0,0 +1,27 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from e2b_code_interpreter import Sandbox
from arcade_e2b.enums import E2BSupportedLanguage
# See https://e2b.dev/docs to learn more about E2B
@tool(requires_secrets=["E2B_API_KEY"])
def run_code(
context: ToolContext,
code: Annotated[str, "The code to run"],
language: Annotated[
E2BSupportedLanguage, "The language of the code"
] = E2BSupportedLanguage.PYTHON,
) -> Annotated[str, "The sandbox execution as a JSON string"]:
"""
Run code in a sandbox and return the output.
"""
api_key = context.get_secret("E2B_API_KEY")
with Sandbox(api_key=api_key) as sbx:
execution = sbx.run_code(code=code, language=language)
return str(execution.to_json())

View file

@ -0,0 +1,120 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
SimilarityCritic,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_e2b
from arcade_e2b.enums import E2BSupportedLanguage
from arcade_e2b.tools.create_chart import create_static_matplotlib_chart
from arcade_e2b.tools.run_code import run_code
merge_sort_code = """
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i, j = 0, 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
sample_list = ["banana", "apple", "cherry", "date", "elderberry"]
sorted_list = merge_sort(sample_list)
print("Sorted list:", sorted_list)
"""
matplotlib_chart_code = """
import matplotlib.pyplot as plt
labels = ['Apples', 'Bananas', 'Cherries', 'Dates']
sizes = [30, 25, 20, 25]
colors = ['red', 'yellow', 'purple', 'brown']
plt.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90)
plt.axis('equal')
plt.title('Fruit Distribution')
plt.savefig('fruit_pie_chart.png')
"""
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_e2b)
@tool_eval()
def e2b_eval_suite():
suite = EvalSuite(
name="E2B Tools Evaluation",
system_message="You are an AI assistant with access to E2B tools. Use them to help the user with their tasks.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Run code",
user_message=f"Can you please run my merge sort algo?\n\n{merge_sort_code}",
expected_tool_calls=[
ExpectedToolCall(
func=run_code,
args={
"code": merge_sort_code,
"language": E2BSupportedLanguage.PYTHON,
},
)
],
critics=[
SimilarityCritic(critic_field="code", weight=0.8),
BinaryCritic(critic_field="language", weight=0.2),
],
)
suite.add_case(
name="Create static matplotlib chart",
user_message=f"Run this code:\n\n{matplotlib_chart_code}",
expected_tool_calls=[
ExpectedToolCall(
func=create_static_matplotlib_chart,
args={
"code": matplotlib_chart_code,
},
)
],
critics=[
SimilarityCritic(critic_field="code", weight=1.0),
],
)
return suite

View file

@ -0,0 +1,57 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_e2b"
version = "2.0.0"
description = "Arcade.dev LLM tools for running code in a sandbox using E2B"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"e2b-code-interpreter>=1.0.1,<2.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"pytest-mock>=3.11.1,<3.12.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = {path = "../../", editable = true}
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
[tool.mypy]
files = [ "arcade_e2b/**/*.py",]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = [ "tests",]
[tool.coverage.report]
skip_empty = true
[tool.hatch.build.targets.wheel]
packages = [ "arcade_e2b",]

View file

View file

@ -0,0 +1,74 @@
from unittest.mock import MagicMock, patch
import pytest
from arcade_tdk import ToolContext, ToolSecretItem
from arcade_tdk.errors import ToolExecutionError
from arcade_e2b.enums import E2BSupportedLanguage
from arcade_e2b.tools.create_chart import create_static_matplotlib_chart
from arcade_e2b.tools.run_code import run_code
@pytest.fixture
def mock_run_code_sandbox():
with patch("arcade_e2b.tools.run_code.Sandbox") as mock:
yield mock.return_value.__enter__.return_value
@pytest.fixture
def mock_create_chart_sandbox():
with patch("arcade_e2b.tools.create_chart.Sandbox") as mock:
yield mock.return_value.__enter__.return_value
@pytest.fixture
def mock_context():
return ToolContext(secrets=[ToolSecretItem(key="e2b_api_key", value="fake_api_key")])
def test_run_code_success(mock_run_code_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.to_json.return_value = '{"result": "success"}'
mock_run_code_sandbox.run_code.return_value = mock_execution
result = run_code(mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON)
assert result == '{"result": "success"}'
def test_run_code_error(mock_run_code_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.to_json.side_effect = ToolExecutionError("Execution failed")
mock_run_code_sandbox.run_code.return_value = mock_execution
with pytest.raises(ToolExecutionError, match="Execution failed"):
run_code(mock_context, "print('Hello, World!')", E2BSupportedLanguage.PYTHON)
def test_create_static_matplotlib_chart_success(mock_create_chart_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.results = [MagicMock(png="base64encodedimage")]
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
mock_execution.error = None
mock_create_chart_sandbox.run_code.return_value = mock_execution
result = create_static_matplotlib_chart(mock_context, "import matplotlib.pyplot as plt")
assert result == {
"base64_image": "base64encodedimage",
"logs": '{"logs": "log data"}',
"error": None,
}
def test_create_static_matplotlib_chart_error(mock_create_chart_sandbox, mock_context):
mock_execution = MagicMock()
mock_execution.results = []
mock_execution.logs.to_json.return_value = '{"logs": "log data"}'
mock_execution.error.to_json.return_value = '{"error": "some error"}'
mock_create_chart_sandbox.run_code.return_value = mock_execution
result = create_static_matplotlib_chart(mock_context, "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: ^.*/firecrawl/.*
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

View file

@ -0,0 +1,47 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025, Arcade AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,55 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -0,0 +1,17 @@
from arcade_firecrawl.tools import (
cancel_crawl,
crawl_website,
get_crawl_data,
get_crawl_status,
map_website,
scrape_url,
)
__all__ = [
"cancel_crawl",
"crawl_website",
"get_crawl_data",
"get_crawl_status",
"map_website",
"scrape_url",
]

View file

@ -0,0 +1,11 @@
from enum import Enum
# Models and enums for firecrawl web tools
class Formats(str, Enum):
MARKDOWN = "markdown"
HTML = "html"
RAW_HTML = "rawHtml"
LINKS = "links"
SCREENSHOT = "screenshot"
SCREENSHOT_AT_FULL_PAGE = "screenshot@fullPage"

View file

@ -0,0 +1,17 @@
from arcade_firecrawl.tools.crawl import (
cancel_crawl,
crawl_website,
get_crawl_data,
get_crawl_status,
)
from arcade_firecrawl.tools.map import map_website
from arcade_firecrawl.tools.scrape import scrape_url
__all__ = [
"cancel_crawl",
"crawl_website",
"get_crawl_data",
"get_crawl_status",
"map_website",
"scrape_url",
]

View file

@ -0,0 +1,121 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from firecrawl import FirecrawlApp
# TODO: Support scrapeOptions.
@tool(requires_secrets=["FIRECRAWL_API_KEY"])
async def crawl_website(
context: ToolContext,
url: Annotated[str, "URL to crawl"],
exclude_paths: Annotated[list[str] | None, "URL patterns to exclude from the crawl"] = None,
include_paths: Annotated[list[str] | None, "URL patterns to include in the crawl"] = None,
max_depth: Annotated[int, "Maximum depth to crawl relative to the entered URL"] = 2,
ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True,
limit: Annotated[int, "Limit the number of pages to crawl"] = 10,
allow_backward_links: Annotated[
bool,
"Enable navigation to previously linked pages and enable crawling "
"sublinks that are not children of the 'url' input parameter.",
] = False,
allow_external_links: Annotated[bool, "Allow following links to external websites"] = False,
webhook: Annotated[
str | None,
"The URL to send a POST request to when the crawl is started, updated and completed.",
] = None,
async_crawl: Annotated[bool, "Run the crawl asynchronously"] = True,
) -> Annotated[dict[str, Any], "Crawl status and data"]:
"""
Crawl a website using Firecrawl. If the crawl is asynchronous, then returns the crawl ID.
If the crawl is synchronous, then returns the crawl data.
"""
api_key = context.get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
params = {
"limit": limit,
"excludePaths": exclude_paths or [],
"includePaths": include_paths or [],
"maxDepth": max_depth,
"ignoreSitemap": ignore_sitemap,
"allowBackwardLinks": allow_backward_links,
"allowExternalLinks": allow_external_links,
}
if webhook:
params["webhook"] = webhook
if async_crawl:
response = app.async_crawl_url(url, params=params)
response.pop("url", None) # Remove 'url' as it's an API endpoint
if response["success"]:
response["status"] = await get_crawl_status(context, response["id"])
response["llm_instructions"] = (
"You have the ability to get crawl status, cancel a crawl, "
"and get a crawl's data. Inform the user that you have these capabilities. "
"Inform the user that they should let you know if they want you to perform any "
"of these actions."
)
else:
response = app.crawl_url(url, params=params)
return dict(response)
@tool(requires_secrets=["FIRECRAWL_API_KEY"])
async def get_crawl_status(
context: ToolContext,
crawl_id: Annotated[str, "The ID of the crawl job"],
) -> Annotated[dict[str, Any], "Crawl status information"]:
"""
Get the status of a Firecrawl 'crawl' that is either in progress or recently completed.
"""
api_key = context.get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
crawl_status = app.check_crawl_status(crawl_id)
crawl_status.pop("data", None) # Remove 'data' if it exists
crawl_status.pop("next", None) # Remove 'next' as it's an API endpoint
return dict(crawl_status)
# TODO: Support responses greater than 10 MB. If the response is greater than 10 MB,
# then the Firecrawl API response will have a next_url field.
@tool(requires_secrets=["FIRECRAWL_API_KEY"])
async def get_crawl_data(
context: ToolContext,
crawl_id: Annotated[str, "The ID of the crawl job"],
) -> Annotated[dict[str, Any], "Crawl data information"]:
"""
Get the data of a Firecrawl 'crawl' that is either in progress or recently completed.
"""
api_key = context.get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
crawl_data = app.check_crawl_status(crawl_id)
return dict(crawl_data)
@tool(requires_secrets=["FIRECRAWL_API_KEY"])
async def cancel_crawl(
context: ToolContext,
crawl_id: Annotated[str, "The ID of the asynchronous crawl job to cancel"],
) -> Annotated[dict[str, Any], "Cancellation status information"]:
"""
Cancel an asynchronous crawl job that is in progress using the Firecrawl API.
"""
api_key = context.get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
cancellation_status = app.cancel_crawl(crawl_id)
return dict(cancellation_status)

View file

@ -0,0 +1,33 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from firecrawl import FirecrawlApp
@tool(requires_secrets=["FIRECRAWL_API_KEY"])
async def map_website(
context: ToolContext,
url: Annotated[str, "The base URL to start crawling from"],
search: Annotated[str | None, "Search query to use for mapping"] = None,
ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True,
include_subdomains: Annotated[bool, "Include subdomains of the website"] = False,
limit: Annotated[int, "Maximum number of links to return"] = 5000,
) -> Annotated[dict[str, Any], "Website map data"]:
"""
Map a website from a single URL to a map of the entire website.
"""
api_key = context.get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
params: dict[str, Any] = {
"ignoreSitemap": ignore_sitemap,
"includeSubdomains": include_subdomains,
"limit": limit,
}
if search:
params["search"] = search
map_result = app.map_url(url, params=params)
return dict(map_result)

View file

@ -0,0 +1,49 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from firecrawl import FirecrawlApp
from arcade_firecrawl.enums import Formats
# TODO: Support actions. This would enable clicking, scrolling, screenshotting, etc.
# TODO: Support extract.
# TODO: Support headers param?
@tool(requires_secrets=["FIRECRAWL_API_KEY"])
async def scrape_url(
context: ToolContext,
url: Annotated[str, "URL to scrape"],
formats: Annotated[
list[Formats] | None, "Formats to retrieve. Defaults to ['markdown']."
] = None,
only_main_content: Annotated[
bool | None,
"Only return the main content of the page excluding headers, navs, footers, etc.",
] = True,
include_tags: Annotated[list[str] | None, "List of tags to include in the output"] = None,
exclude_tags: Annotated[list[str] | None, "List of tags to exclude from the output"] = None,
wait_for: Annotated[
int | None,
"Specify a delay in milliseconds before fetching the content, allowing the page "
"sufficient time to load.",
] = 10,
timeout: Annotated[int | None, "Timeout in milliseconds for the request"] = 30000,
) -> Annotated[dict[str, Any], "Scraped data in specified formats"]:
"""Scrape a URL using Firecrawl and return the data in specified formats."""
api_key = context.get_secret("FIRECRAWL_API_KEY")
formats = formats or [Formats.MARKDOWN]
app = FirecrawlApp(api_key=api_key)
params = {
"formats": formats,
"onlyMainContent": only_main_content,
"includeTags": include_tags or [],
"excludeTags": exclude_tags or [],
"waitFor": wait_for,
"timeout": timeout,
}
response = app.scrape_url(url, params=params)
return dict(response)

View file

@ -0,0 +1,244 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
NumericCritic,
SimilarityCritic,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_firecrawl
from arcade_firecrawl.tools import (
cancel_crawl,
crawl_website,
get_crawl_data,
get_crawl_status,
map_website,
scrape_url,
)
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
# Register the Firecrawl tools
catalog.add_module(arcade_firecrawl)
@tool_eval()
def firecrawl_eval_suite() -> EvalSuite:
"""Evaluation suite for Firecrawl tools."""
suite = EvalSuite(
name="Firecrawl Tools Evaluation Suite",
system_message="You are an AI assistant that helps users interact with web scraping and crawling tools using the provided tools.",
catalog=catalog,
rubric=rubric,
)
# Scrape URL
suite.add_case(
name="Scrape a URL",
user_message="Scrape https://foobar.com/howto/tutorials/join-discord-server in markdown format please. Wait for 10 seconds before fetching the content.",
expected_tool_calls=[
ExpectedToolCall(
func=scrape_url,
args={
"url": "https://foobar.com/howto/tutorials/join-discord-server",
"formats": ["markdown"],
"wait_for": 10000,
},
)
],
critics=[
BinaryCritic(critic_field="url", weight=0.4),
BinaryCritic(critic_field="formats", weight=0.4),
NumericCritic(critic_field="wait_for", weight=0.2, value_range=(9000, 11000)),
],
)
# Crawl Website
suite.add_case(
name="Crawl a website",
user_message="Crawl the website at https://wikipedia.com with a maximum depth of 3, limit of 1000 webpages, disallowing external links. Updates should be sent to http://example.com/crawl-updates. Oh and do it in the background. THanks",
expected_tool_calls=[
ExpectedToolCall(
func=crawl_website,
args={
"url": "https://wikipedia.com",
"max_depth": 3,
"limit": 1000,
"allow_external_links": False,
"webhook": "http://example.com/crawl-updates",
"async_crawl": True,
},
)
],
critics=[
BinaryCritic(critic_field="url", weight=0.2),
BinaryCritic(critic_field="max_depth", weight=0.1),
BinaryCritic(critic_field="limit", weight=0.1),
BinaryCritic(critic_field="allow_external_links", weight=0.1),
BinaryCritic(critic_field="webhook", weight=0.2),
BinaryCritic(critic_field="async_crawl", weight=0.2),
],
)
# Get Crawl Status
suite.add_case(
name="Get crawl status",
user_message="Check the status of my crawl",
expected_tool_calls=[
ExpectedToolCall(
func=get_crawl_status,
args={
"crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b",
},
)
],
critics=[
BinaryCritic(critic_field="crawl_id", weight=1.0),
],
additional_messages=[
{"role": "user", "content": "crawl asynchronously https://www.google.com"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_QklpRSDmHdvM3ZZfzOqCKWRN",
"type": "function",
"function": {
"name": "Firecrawl_CrawlWebsite",
"arguments": '{"url":"https://www.google.com","async_crawl":true}',
},
}
],
},
{
"role": "tool",
"content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}',
"tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN",
"name": "Firecrawl_CrawlWebsite",
},
{
"role": "assistant",
"content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).",
},
],
)
# # Get Crawl Data
suite.add_case(
name="Get crawl status",
user_message="Ok looks like the crawl is done, can I get the result please?",
expected_tool_calls=[
ExpectedToolCall(
func=get_crawl_data,
args={
"crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b",
},
)
],
critics=[
BinaryCritic(critic_field="crawl_id", weight=1.0),
],
additional_messages=[
{"role": "user", "content": "crawl asynchronously https://www.google.com"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_QklpRSDmHdvM3ZZfzOqCKWRN",
"type": "function",
"function": {
"name": "Firecrawl_CrawlWebsite",
"arguments": '{"url":"https://www.google.com","async_crawl":true}',
},
}
],
},
{
"role": "tool",
"content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}',
"tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN",
"name": "Firecrawl_CrawlWebsite",
},
{
"role": "assistant",
"content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).",
},
],
)
# Cancel Crawl
suite.add_case(
name="Get crawl status",
user_message="Actually cancel it.",
expected_tool_calls=[
ExpectedToolCall(
func=cancel_crawl,
args={
"crawl_id": "2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b",
},
)
],
critics=[
BinaryCritic(critic_field="crawl_id", weight=1.0),
],
additional_messages=[
{"role": "user", "content": "crawl asynchronously https://www.google.com"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_QklpRSDmHdvM3ZZfzOqCKWRN",
"type": "function",
"function": {
"name": "Firecrawl_CrawlWebsite",
"arguments": '{"url":"https://www.google.com","async_crawl":true}',
},
}
],
},
{
"role": "tool",
"content": '{"id":"2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b","success":true,"url":"https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b"}',
"tool_call_id": "call_QklpRSDmHdvM3ZZfzOqCKWRN",
"name": "Firecrawl_CrawlWebsite",
},
{
"role": "assistant",
"content": "The asynchronous web crawl request for [Google](https://www.google.com) has been successfully initiated. You can track the status or fetch the results using the following [link](https://api.firecrawl.dev/v1/crawl/2ee7ba77-4ba0-4a45-9e2f-1c9e9a56f29b).",
},
],
)
# Map Website
suite.add_case(
name="Map a website",
user_message="Map the website at https://wikipedia.com with a limit of 100000 links. Only the links that are about the topic of AI",
expected_tool_calls=[
ExpectedToolCall(
func=map_website,
args={
"url": "https://wikipedia.com",
"search": "AI",
"limit": 100000,
},
)
],
critics=[
BinaryCritic(critic_field="url", weight=0.4),
SimilarityCritic(critic_field="search", weight=0.2),
NumericCritic(critic_field="limit", weight=0.4, value_range=(90000, 110000)),
],
)
return suite

View file

@ -0,0 +1,54 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_firecrawl"
version = "2.0.0"
description = "Arcade.dev LLM tools for web scraping related tasks via Firecrawl"
requires-python = ">=3.10"
dependencies = [ "arcade-tdk>=2.0.0,<3.0.0", "firecrawl-py>=1.3.1,<2.0.0",]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"pytest-mock>=3.11.1,<3.12.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = {path = "../../", editable = true}
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
[tool.mypy]
files = [ "arcade_firecrawl/**/*.py",]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = [ "tests",]
[tool.coverage.report]
skip_empty = true
[tool.hatch.build.targets.wheel]
packages = [ "arcade_firecrawl",]

View file

View file

@ -0,0 +1,129 @@
from unittest.mock import patch
import pytest
from arcade_tdk import ToolContext, ToolSecretItem
from arcade_tdk.errors import ToolExecutionError
from arcade_firecrawl.tools import (
cancel_crawl,
crawl_website,
get_crawl_data,
get_crawl_status,
map_website,
scrape_url,
)
@pytest.fixture
def mock_context():
return ToolContext(secrets=[ToolSecretItem(key="firecrawl_api_key", value="fake_api_key")])
@pytest.fixture
def mock_firecrawl_app_for_scrape():
with patch("arcade_firecrawl.tools.scrape.FirecrawlApp") as app:
yield app.return_value
@pytest.fixture
def mock_firecrawl_app_for_crawl():
with patch("arcade_firecrawl.tools.crawl.FirecrawlApp") as app:
yield app.return_value
@pytest.fixture
def mock_firecrawl_app_for_map():
with patch("arcade_firecrawl.tools.map.FirecrawlApp") as app:
yield app.return_value
@pytest.mark.asyncio
async def test_scrape_url_success(mock_firecrawl_app_for_scrape, mock_context):
expected_response = {
"success": True,
"data": {"scraped_content": "scraped content"},
}
mock_firecrawl_app_for_scrape.scrape_url.return_value = expected_response
result = await scrape_url(mock_context, "http://example.com")
assert result == expected_response
@pytest.mark.asyncio
async def test_crawl_website_success(mock_firecrawl_app_for_crawl, mock_context):
expected_response = {
"id": "12345",
"success": True,
}
mock_firecrawl_app_for_crawl.async_crawl_url.return_value = expected_response
mock_firecrawl_app_for_crawl.check_crawl_status.return_value = expected_response
result = await crawl_website(mock_context, "http://example.com")
assert result == expected_response
@pytest.mark.asyncio
async def test_get_crawl_status_success(mock_firecrawl_app_for_crawl, mock_context):
expected_response = {"status": "completed"}
mock_firecrawl_app_for_crawl.check_crawl_status.return_value = expected_response
result = await get_crawl_status(mock_context, "12345")
assert result == expected_response
@pytest.mark.asyncio
async def test_get_crawl_data_success(mock_firecrawl_app_for_crawl, mock_context):
expected_response = {"data": "crawl data"}
mock_firecrawl_app_for_crawl.check_crawl_status.return_value = expected_response
result = await get_crawl_data(mock_context, "12345")
assert result == expected_response
@pytest.mark.asyncio
async def test_cancel_crawl_success(mock_firecrawl_app_for_crawl, mock_context):
expected_response = {"status": "cancelled"}
mock_firecrawl_app_for_crawl.cancel_crawl.return_value = expected_response
result = await cancel_crawl(mock_context, "12345")
assert result == expected_response
@pytest.mark.asyncio
async def test_map_website_success(mock_firecrawl_app_for_map, mock_context):
expected_response = {"map": "website map"}
mock_firecrawl_app_for_map.map_url.return_value = expected_response
result = await map_website(mock_context, "http://example.com")
assert result == expected_response
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method,params,error_message",
[
(scrape_url, ("http://example.com",), "Error scraping URL"),
(crawl_website, ("http://example.com",), "Error crawling website"),
(get_crawl_status, ("12345",), "Error getting crawl status"),
(get_crawl_data, ("12345",), "Error getting crawl data"),
(cancel_crawl, ("12345",), "Error cancelling crawl"),
(map_website, ("http://example.com",), "Error mapping website"),
],
)
async def test_firecrawl_error(
mock_firecrawl_app_for_scrape,
mock_firecrawl_app_for_crawl,
mock_firecrawl_app_for_map,
mock_context,
method,
params,
error_message,
):
mock_firecrawl_app_for_scrape.scrape_url.side_effect = Exception(error_message)
mock_firecrawl_app_for_crawl.async_crawl_url.side_effect = Exception(error_message)
mock_firecrawl_app_for_crawl.check_crawl_status.side_effect = Exception(error_message)
mock_firecrawl_app_for_crawl.cancel_crawl.side_effect = Exception(error_message)
mock_firecrawl_app_for_map.map_url.side_effect = Exception(error_message)
with pytest.raises(ToolExecutionError):
await method(mock_context, *params)

View file

@ -0,0 +1,18 @@
files: ^.*/gmail/.*
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

46
toolkits/gmail/.ruff.toml Normal file
View file

@ -0,0 +1,46 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

55
toolkits/gmail/Makefile Normal file
View file

@ -0,0 +1,55 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

View file

@ -0,0 +1,18 @@
import os
from arcade_gmail.enums import GmailReplyToWhom
# The default reply in Gmail is to only the sender. Since Gmail also offers the possibility of
# changing the default to 'reply to all', we support both options through an env variable.
# https://support.google.com/mail/answer/6585?hl=en&sjid=15399867888091633568-SA#null
try:
GMAIL_DEFAULT_REPLY_TO = GmailReplyToWhom(
# Values accepted are defined in the arcade_google.tools.models.GmailReplyToWhom Enum
os.getenv("ARCADE_GMAIL_DEFAULT_REPLY_TO", GmailReplyToWhom.ONLY_THE_SENDER.value).lower()
)
except ValueError as e:
raise ValueError(
"Invalid value for ARCADE_GMAIL_DEFAULT_REPLY_TO: "
f"'{os.getenv('ARCADE_GMAIL_DEFAULT_REPLY_TO')}'. Expected one of "
f"{list(GmailReplyToWhom.__members__.keys())}"
) from e

View file

@ -0,0 +1,11 @@
from enum import Enum
class GmailReplyToWhom(str, Enum):
EVERY_RECIPIENT = "every_recipient"
ONLY_THE_SENDER = "only_the_sender"
class GmailAction(str, Enum):
SEND = "send"
DRAFT = "draft"

View file

@ -0,0 +1,19 @@
class GmailToolError(Exception):
"""Base exception for Google tool errors."""
def __init__(self, message: str, developer_message: str | None = None):
self.message = message
self.developer_message = developer_message
super().__init__(self.message)
def __str__(self) -> str:
base_message = self.message
if self.developer_message:
return f"{base_message} (Developer: {self.developer_message})"
return base_message
class GmailServiceError(GmailToolError):
"""Raised when there's an error building or using the Google service."""
pass

View file

@ -0,0 +1,39 @@
from arcade_gmail.tools.gmail import (
change_email_labels,
create_label,
delete_draft_email,
get_thread,
list_draft_emails,
list_emails,
list_emails_by_header,
list_labels,
list_threads,
reply_to_email,
search_threads,
send_draft_email,
send_email,
trash_email,
update_draft_email,
write_draft_email,
write_draft_reply_email,
)
__all__ = [
"change_email_labels",
"create_label",
"delete_draft_email",
"get_thread",
"list_draft_emails",
"list_emails",
"list_emails_by_header",
"list_labels",
"list_threads",
"reply_to_email",
"search_threads",
"send_draft_email",
"send_email",
"trash_email",
"update_draft_email",
"write_draft_email",
"write_draft_reply_email",
]

View file

@ -0,0 +1,664 @@
import base64
from email.mime.text import MIMEText
from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Google
from arcade_tdk.errors import RetryableToolError
from googleapiclient.errors import HttpError
from arcade_gmail.constants import GMAIL_DEFAULT_REPLY_TO
from arcade_gmail.enums import GmailAction, GmailReplyToWhom
from arcade_gmail.exceptions import GmailToolError
from arcade_gmail.utils import (
DateRange,
_build_gmail_service,
build_email_message,
build_gmail_query_string,
build_reply_recipients,
fetch_messages,
get_draft_url,
get_email_details,
get_email_in_trash_url,
get_label_ids,
get_sent_email_url,
parse_draft_email,
parse_multipart_email,
parse_plain_text_email,
remove_none_values,
)
# Email sending tools
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.send"],
)
)
async def send_email(
context: ToolContext,
subject: Annotated[str, "The subject of the email"],
body: Annotated[str, "The body of the email"],
recipient: Annotated[str, "The recipient of the email"],
cc: Annotated[list[str] | None, "CC recipients of the email"] = None,
bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None,
) -> Annotated[dict, "A dictionary containing the sent email details"]:
"""
Send an email using the Gmail API.
"""
service = _build_gmail_service(context)
email = build_email_message(recipient, subject, body, cc, bcc)
sent_message = service.users().messages().send(userId="me", body=email).execute()
email = parse_plain_text_email(sent_message)
email["url"] = get_sent_email_url(sent_message["id"])
return email
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.send"],
)
)
async def send_draft_email(
context: ToolContext, email_id: Annotated[str, "The ID of the draft to send"]
) -> Annotated[dict, "A dictionary containing the sent email details"]:
"""
Send a draft email using the Gmail API.
"""
service = _build_gmail_service(context)
# Send the draft email
sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute()
email = parse_plain_text_email(sent_message)
email["url"] = get_sent_email_url(sent_message["id"])
return email
# Note: in the Gmail UI, a user can customize the recipient and cc fields before replying.
# We decided not to support this feature, since we'd need a way for LLMs to tell apart between
# adding or removing recipients/cc, or replacing with an entirely new list of addresses,
# which would make the tool more complex to call.
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.send"],
)
)
async def reply_to_email(
context: ToolContext,
body: Annotated[str, "The body of the email"],
reply_to_message_id: Annotated[str, "The ID of the message to reply to"],
reply_to_whom: Annotated[
GmailReplyToWhom,
"Whether to reply to every recipient (including cc) or only to the original sender. "
f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.",
] = GMAIL_DEFAULT_REPLY_TO,
bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None,
) -> Annotated[dict, "A dictionary containing the sent email details"]:
"""
Send a reply to an email message.
"""
if isinstance(reply_to_whom, str):
reply_to_whom = GmailReplyToWhom(reply_to_whom)
service = _build_gmail_service(context)
current_user = service.users().getProfile(userId="me").execute()
try:
replying_to_email = (
service.users().messages().get(userId="me", id=reply_to_message_id).execute()
)
except HttpError as e:
raise RetryableToolError(
message=f"Could not retrieve the message with id {reply_to_message_id}.",
developer_message=(
f"Could not retrieve the message with id {reply_to_message_id}. "
f"Reason: '{e.reason}'. Error details: '{e.error_details}'"
),
) from e
replying_to_email = parse_multipart_email(replying_to_email)
recipients = build_reply_recipients(
replying_to_email, current_user["emailAddress"], reply_to_whom
)
email = build_email_message(
recipient=recipients,
subject=f"Re: {replying_to_email['subject']}",
body=body,
cc=None
if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER
else replying_to_email["cc"].split(","),
bcc=bcc,
replying_to=replying_to_email,
)
sent_message = service.users().messages().send(userId="me", body=email).execute()
email = parse_plain_text_email(sent_message)
email["url"] = get_sent_email_url(sent_message["id"])
return email
# Draft Management Tools
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.compose"],
)
)
async def write_draft_email(
context: ToolContext,
subject: Annotated[str, "The subject of the draft email"],
body: Annotated[str, "The body of the draft email"],
recipient: Annotated[str, "The recipient of the draft email"],
cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None,
bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None,
) -> Annotated[dict, "A dictionary containing the created draft email details"]:
"""
Compose a new email draft using the Gmail API.
"""
# Set up the Gmail API client
service = _build_gmail_service(context)
draft = {
"message": build_email_message(recipient, subject, body, cc, bcc, action=GmailAction.DRAFT)
}
draft_message = service.users().drafts().create(userId="me", body=draft).execute()
email = parse_draft_email(draft_message)
email["url"] = get_draft_url(draft_message["id"])
return email
# Note: in the Gmail UI, a user can customize the recipient and cc fields before replying.
# We decided not to support this feature, since we'd need a way for LLMs to tell apart between
# adding or removing recipients/cc, or replacing with an entirely new list of addresses,
# which would make the tool more complex to call.
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.compose"],
)
)
async def write_draft_reply_email(
context: ToolContext,
body: Annotated[str, "The body of the draft reply email"],
reply_to_message_id: Annotated[str, "The Gmail message ID of the message to draft a reply to"],
reply_to_whom: Annotated[
GmailReplyToWhom,
"Whether to reply to every recipient (including cc) or only to the original sender. "
f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.",
] = GMAIL_DEFAULT_REPLY_TO,
bcc: Annotated[list[str] | None, "BCC recipients of the draft reply email"] = None,
) -> Annotated[dict, "A dictionary containing the created draft reply email details"]:
"""
Compose a draft reply to an email message.
"""
if isinstance(reply_to_whom, str):
reply_to_whom = GmailReplyToWhom(reply_to_whom)
service = _build_gmail_service(context)
current_user = service.users().getProfile(userId="me").execute()
try:
replying_to_email = (
service.users().messages().get(userId="me", id=reply_to_message_id).execute()
)
except HttpError as e:
raise RetryableToolError(
message="Could not retrieve the message to respond to.",
developer_message=(
"Could not retrieve the message to respond to. "
f"Reason: '{e.reason}'. Error details: '{e.error_details}'"
),
)
replying_to_email = parse_multipart_email(replying_to_email)
recipients = build_reply_recipients(
replying_to_email, current_user["emailAddress"], reply_to_whom
)
draft_message = {
"message": build_email_message(
recipient=recipients,
subject=f"Re: {replying_to_email['subject']}",
body=body,
cc=None
if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER
else replying_to_email["cc"].split(","),
bcc=bcc,
replying_to=replying_to_email,
action=GmailAction.DRAFT,
),
}
draft = service.users().drafts().create(userId="me", body=draft_message).execute()
email = parse_draft_email(draft)
email["url"] = get_draft_url(draft["id"])
return email
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.compose"],
)
)
async def update_draft_email(
context: ToolContext,
draft_email_id: Annotated[str, "The ID of the draft email to update."],
subject: Annotated[str, "The subject of the draft email"],
body: Annotated[str, "The body of the draft email"],
recipient: Annotated[str, "The recipient of the draft email"],
cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None,
bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None,
) -> Annotated[dict, "A dictionary containing the updated draft email details"]:
"""
Update an existing email draft using the Gmail API.
"""
service = _build_gmail_service(context)
message = MIMEText(body)
message["to"] = recipient
message["subject"] = subject
if cc:
message["Cc"] = ", ".join(cc)
if bcc:
message["Bcc"] = ", ".join(bcc)
# Encode the message in base64
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
# Update the draft
draft = {"id": draft_email_id, "message": {"raw": raw_message}}
updated_draft_message = (
service.users().drafts().update(userId="me", id=draft_email_id, body=draft).execute()
)
email = parse_draft_email(updated_draft_message)
email["url"] = get_draft_url(updated_draft_message["id"])
return email
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.compose"],
)
)
async def delete_draft_email(
context: ToolContext,
draft_email_id: Annotated[str, "The ID of the draft email to delete"],
) -> Annotated[str, "A confirmation message indicating successful deletion"]:
"""
Delete a draft email using the Gmail API.
"""
service = _build_gmail_service(context)
# Delete the draft
service.users().drafts().delete(userId="me", id=draft_email_id).execute()
return f"Draft email with ID {draft_email_id} deleted successfully."
# Email Management Tools
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.modify"],
)
)
async def trash_email(
context: ToolContext, email_id: Annotated[str, "The ID of the email to trash"]
) -> Annotated[dict, "A dictionary containing the trashed email details"]:
"""
Move an email to the trash folder using the Gmail API.
"""
service = _build_gmail_service(context)
# Trash the email
trashed_email = service.users().messages().trash(userId="me", id=email_id).execute()
email = parse_plain_text_email(trashed_email)
email["url"] = get_email_in_trash_url(trashed_email["id"])
return email
# Draft Search Tools
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
)
async def list_draft_emails(
context: ToolContext,
n_drafts: Annotated[int, "Number of draft emails to read"] = 5,
) -> Annotated[dict, "A dictionary containing a list of draft email details"]:
"""
Lists draft emails in the user's draft mailbox using the Gmail API.
"""
service = _build_gmail_service(context)
listed_drafts = service.users().drafts().list(userId="me").execute()
if not listed_drafts:
return {"emails": []}
draft_ids = [draft["id"] for draft in listed_drafts.get("drafts", [])][:n_drafts]
emails = []
for draft_id in draft_ids:
try:
draft_data = service.users().drafts().get(userId="me", id=draft_id).execute()
draft_details = parse_draft_email(draft_data)
if draft_details:
emails.append(draft_details)
except Exception as e:
raise GmailToolError(
message=f"Error reading draft email {draft_id}.", developer_message=str(e)
)
return {"emails": emails}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
)
async def list_emails_by_header(
context: ToolContext,
sender: Annotated[str | None, "The name or email address of the sender of the email"] = None,
recipient: Annotated[str | None, "The name or email address of the recipient"] = None,
subject: Annotated[str | None, "Words to find in the subject of the email"] = None,
body: Annotated[str | None, "Words to find in the body of the email"] = None,
date_range: Annotated[DateRange | None, "The date range of the email"] = None,
label: Annotated[str | None, "The label name to filter by"] = None,
max_results: Annotated[int, "The maximum number of emails to return"] = 25,
) -> Annotated[
dict, "A dictionary containing a list of email details matching the search criteria"
]:
"""
Search for emails by header using the Gmail API.
At least one of the following parameters MUST be provided: sender, recipient,
subject, date_range, label, or body.
"""
service = _build_gmail_service(context)
# Ensure at least one search parameter is provided
if not any([sender, recipient, subject, body, label, date_range]):
raise RetryableToolError(
message=(
"At least one of sender, recipient, subject, body, label, query, "
"or date_range must be provided."
),
developer_message=(
"At least one of sender, recipient, subject, body, label, query, "
"or date_range must be provided."
),
)
# Check if label is valid
if label:
label_ids = get_label_ids(service, [label])
if not label_ids:
labels = service.users().labels().list(userId="me").execute().get("labels", [])
label_names = [label["name"] for label in labels]
raise RetryableToolError(
message=f"Invalid label: {label}",
developer_message=f"Invalid label: {label}",
additional_prompt_content=f"List of valid labels: {label_names}",
)
# Build a Gmail-style query string based on the filters
query = build_gmail_query_string(sender, recipient, subject, body, date_range, label)
# Fetch matching messages. This fetches message metadata from Gmail
messages = fetch_messages(service, query, max_results)
# If no messages found, return an empty list
if not messages:
return {"emails": []}
# Process each message into a structured email object
emails = get_email_details(service, messages)
# Return the list of emails in a dictionary with key "emails"
return {"emails": emails}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
)
async def list_emails(
context: ToolContext,
n_emails: Annotated[int, "Number of emails to read"] = 5,
) -> Annotated[dict, "A dictionary containing a list of email details"]:
"""
Read emails from a Gmail account and extract plain text content.
"""
service = _build_gmail_service(context)
messages = service.users().messages().list(userId="me").execute().get("messages", [])
if not messages:
return {"emails": []}
emails = []
for msg in messages[:n_emails]:
try:
email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
email_details = parse_plain_text_email(email_data)
if email_details:
emails.append(email_details)
except Exception as e:
raise GmailToolError(
message=f"Error reading email {msg['id']}.", developer_message=str(e)
)
return {"emails": emails}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
)
async def search_threads(
context: ToolContext,
page_token: Annotated[
str | None, "Page token to retrieve a specific page of results in the list"
] = None,
max_results: Annotated[int, "The maximum number of threads to return"] = 10,
include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False,
label_ids: Annotated[list[str] | None, "The IDs of labels to filter by"] = None,
sender: Annotated[str | None, "The name or email address of the sender of the email"] = None,
recipient: Annotated[str | None, "The name or email address of the recipient"] = None,
subject: Annotated[str | None, "Words to find in the subject of the email"] = None,
body: Annotated[str | None, "Words to find in the body of the email"] = None,
date_range: Annotated[DateRange | None, "The date range of the email"] = None,
) -> Annotated[dict, "A dictionary containing a list of thread details"]:
"""Search for threads in the user's mailbox"""
service = _build_gmail_service(context)
query = (
build_gmail_query_string(sender, recipient, subject, body, date_range)
if any([sender, recipient, subject, body, date_range])
else None
)
params = {
"userId": "me",
"maxResults": min(max_results, 500),
"pageToken": page_token,
"includeSpamTrash": include_spam_trash,
"labelIds": label_ids,
"q": query,
}
params = remove_none_values(params)
threads: list[dict[str, Any]] = []
next_page_token = None
# Paginate through thread pages until we have the desired number of threads
while len(threads) < max_results:
response = service.users().threads().list(**params).execute()
threads.extend(response.get("threads", []))
next_page_token = response.get("nextPageToken")
if not next_page_token:
break
params["pageToken"] = next_page_token
params["maxResults"] = min(max_results - len(threads), 500)
return {
"threads": threads,
"num_threads": len(threads),
"next_page_token": next_page_token,
}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
)
async def list_threads(
context: ToolContext,
page_token: Annotated[
str | None, "Page token to retrieve a specific page of results in the list"
] = None,
max_results: Annotated[int, "The maximum number of threads to return"] = 10,
include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False,
) -> Annotated[dict, "A dictionary containing a list of thread details"]:
"""List threads in the user's mailbox."""
threads: dict[str, Any] = await search_threads(
context, page_token, max_results, include_spam_trash
)
return threads
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
)
async def get_thread(
context: ToolContext,
thread_id: Annotated[str, "The ID of the thread to retrieve"],
) -> Annotated[dict, "A dictionary containing the thread details"]:
"""Get the specified thread by ID."""
params = {
"userId": "me",
"id": thread_id,
"format": "full",
}
params = remove_none_values(params)
service = _build_gmail_service(context)
thread = service.users().threads().get(**params).execute()
thread["messages"] = [parse_plain_text_email(message) for message in thread.get("messages", [])]
return dict(thread)
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.modify"],
)
)
async def change_email_labels(
context: ToolContext,
email_id: Annotated[str, "The ID of the email to modify labels for"],
labels_to_add: Annotated[list[str], "List of label names to add"],
labels_to_remove: Annotated[list[str], "List of label names to remove"],
) -> Annotated[dict, "List of labels that were added, removed, and not found"]:
"""
Add and remove labels from an email using the Gmail API.
"""
service = _build_gmail_service(context)
add_labels = get_label_ids(service, labels_to_add)
remove_labels = get_label_ids(service, labels_to_remove)
invalid_labels = (
set(labels_to_add + labels_to_remove) - set(add_labels.keys()) - set(remove_labels.keys())
)
if invalid_labels:
# prepare the list of valid labels
labels = service.users().labels().list(userId="me").execute().get("labels", [])
label_names = [label["name"] for label in labels]
# raise a retryable error with the list of valid labels
raise RetryableToolError(
message=f"Invalid labels: {invalid_labels}",
developer_message=f"Invalid labels: {invalid_labels}",
additional_prompt_content=f"List of valid labels: {label_names}",
)
# Prepare the modification body with label IDs.
body = {
"addLabelIds": list(add_labels.values()),
"removeLabelIds": list(remove_labels.values()),
}
try: # Modify the email labels.
service.users().messages().modify(userId="me", id=email_id, body=body).execute()
except Exception as e:
raise GmailToolError(
message=f"Error modifying labels for email {email_id}", developer_message=str(e)
)
# Confirmation JSON with lists for added and removed labels.
confirmation = {
"addedLabels": list(add_labels.keys()),
"removedLabels": list(remove_labels.keys()),
}
return {"confirmation": dict(confirmation)}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
)
async def list_labels(
context: ToolContext,
) -> Annotated[dict, "A dictionary containing a list of label details"]:
"""List all the labels in the user's mailbox."""
service = _build_gmail_service(context)
labels = service.users().labels().list(userId="me").execute().get("labels", [])
return {"labels": labels}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.labels"],
)
)
async def create_label(
context: ToolContext,
label_name: Annotated[str, "The name of the label to create"],
) -> Annotated[dict, "The details of the created label"]:
"""Create a new label in the user's mailbox."""
service = _build_gmail_service(context)
label = service.users().labels().create(userId="me", body={"name": label_name}).execute()
return {"label": label}

View file

@ -0,0 +1,509 @@
import logging
import re
from base64 import urlsafe_b64decode, urlsafe_b64encode
from datetime import datetime, timedelta
from email.message import EmailMessage
from email.mime.text import MIMEText
from enum import Enum
from typing import Any
from arcade_tdk import ToolContext
from bs4 import BeautifulSoup
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from arcade_gmail.enums import (
GmailAction,
GmailReplyToWhom,
)
from arcade_gmail.exceptions import GmailServiceError, GmailToolError
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
class DateRange(Enum):
TODAY = "today"
YESTERDAY = "yesterday"
LAST_7_DAYS = "last_7_days"
LAST_30_DAYS = "last_30_days"
THIS_MONTH = "this_month"
LAST_MONTH = "last_month"
THIS_YEAR = "this_year"
def to_date_query(self) -> str:
today = datetime.now()
result = "after:"
comparison_date = today
if self == DateRange.YESTERDAY:
comparison_date = today - timedelta(days=1)
elif self == DateRange.LAST_7_DAYS:
comparison_date = today - timedelta(days=7)
elif self == DateRange.LAST_30_DAYS:
comparison_date = today - timedelta(days=30)
elif self == DateRange.THIS_MONTH:
comparison_date = today.replace(day=1)
elif self == DateRange.LAST_MONTH:
comparison_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
elif self == DateRange.THIS_YEAR:
comparison_date = today.replace(month=1, day=1)
elif self == DateRange.LAST_MONTH:
comparison_date = (today.replace(month=1, day=1) - timedelta(days=1)).replace(
month=1, day=1
)
return result + comparison_date.strftime("%Y/%m/%d")
def build_email_message(
recipient: str,
subject: str,
body: str,
cc: list[str] | None = None,
bcc: list[str] | None = None,
replying_to: dict[str, Any] | None = None,
action: GmailAction = GmailAction.SEND,
) -> dict[str, Any]:
if replying_to:
body = build_reply_body(body, replying_to)
message: EmailMessage | MIMEText
if action == GmailAction.SEND:
message = EmailMessage()
message.set_content(body)
elif action == GmailAction.DRAFT:
message = MIMEText(body)
message["To"] = recipient
message["Subject"] = subject
if cc:
message["Cc"] = ",".join(cc)
if bcc:
message["Bcc"] = ",".join(bcc)
if replying_to:
message["In-Reply-To"] = replying_to["header_message_id"]
message["References"] = f"{replying_to['header_message_id']}, {replying_to['references']}"
encoded_message = urlsafe_b64encode(message.as_bytes()).decode()
data = {"raw": encoded_message}
if replying_to:
data["threadId"] = replying_to["thread_id"]
return data
def _build_gmail_service(context: ToolContext) -> Any:
"""
Private helper function to build and return the Gmail service client.
Args:
context (ToolContext): The context containing authorization details.
Returns:
googleapiclient.discovery.Resource: An authorized Gmail API service instance.
"""
try:
credentials = Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
)
except Exception as e:
raise GmailServiceError(message="Failed to build Gmail service.", developer_message=str(e))
return build("gmail", "v1", credentials=credentials)
def build_gmail_query_string(
sender: str | None = None,
recipient: str | None = None,
subject: str | None = None,
body: str | None = None,
date_range: DateRange | None = None,
label: str | None = None,
) -> str:
"""Helper function to build a query string
for Gmail list_emails_by_header and search_threads tools.
"""
query = []
if sender:
query.append(f"from:{sender}")
if recipient:
query.append(f"to:{recipient}")
if subject:
query.append(f"subject:{subject}")
if body:
query.append(body)
if date_range:
query.append(date_range.to_date_query())
if label:
query.append(f"label:{label}")
return " ".join(query)
def get_label_ids(service: Any, label_names: list[str]) -> dict[str, str]:
"""
Retrieve label IDs for given label names.
Returns a dictionary mapping label names to their IDs.
Args:
service: Authenticated Gmail API service instance.
label_names: List of label names to retrieve IDs for.
Returns:
A dictionary mapping found label names to their corresponding IDs.
"""
try:
# Fetch all existing labels from Gmail
labels = service.users().labels().list(userId="me").execute().get("labels", [])
except Exception as e:
raise GmailToolError(message="Failed to list labels.", developer_message=str(e)) from e
# Create a mapping from label names to their IDs
label_id_map = {label["name"]: label["id"] for label in labels}
found_labels = {}
for name in label_names:
label_id = label_id_map.get(name)
if label_id:
found_labels[name] = label_id
else:
logger.warning(f"Label '{name}' does not exist")
return found_labels
def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]:
"""
Helper function to fetch messages from Gmail API for the list_emails_by_header tool.
"""
response = (
service.users()
.messages()
.list(userId="me", q=query_string, maxResults=limit or 100)
.execute()
)
return response.get("messages", []) # type: ignore[no-any-return]
def remove_none_values(params: dict) -> dict:
"""
Remove None values from a dictionary.
:param params: The dictionary to clean
:return: A new dictionary with None values removed
"""
return {k: v for k, v in params.items() if v is not None}
def build_reply_recipients(
replying_to: dict[str, Any], current_user_email_address: str, reply_to_whom: GmailReplyToWhom
) -> str:
if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER:
recipients = [replying_to["from"]]
elif reply_to_whom == GmailReplyToWhom.EVERY_RECIPIENT:
recipients = [replying_to["from"], *replying_to["to"].split(",")]
else:
raise ValueError(f"Unsupported reply_to_whom value: {reply_to_whom}")
recipients = [
email_address.strip()
for email_address in recipients
if email_address.strip().lower() != current_user_email_address.lower().strip()
]
return ", ".join(recipients)
def get_draft_url(draft_id: str) -> str:
return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}"
def get_sent_email_url(sent_email_id: str) -> str:
return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}"
def get_email_details(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Retrieves full message data for each message ID in the given list and extracts email details.
:param service: Authenticated Gmail API service instance.
:param messages: A list of dictionaries, each representing a message with an 'id' key.
:return: A list of dictionaries, each containing parsed email details.
"""
emails = []
for msg in messages:
try:
# Fetch the full message data from Gmail using the message ID
email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
# Parse the raw email data into a structured form
email_details = parse_plain_text_email(email_data)
# Only add the details if parsing was successful
if email_details:
emails.append(email_details)
except Exception as e:
# Log any errors encountered while trying to fetch or parse a message
raise GmailToolError(
message=f"Error reading email {msg['id']}.", developer_message=str(e)
)
return emails
def get_email_in_trash_url(email_id: str) -> str:
return f"https://mail.google.com/mail/u/0/#trash/{email_id}"
def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]:
"""
Parse draft email data and extract relevant information.
Args:
draft_email_data (Dict[str, Any]): Raw draft email data from Gmail API.
Returns:
dict[str, str]: Parsed draft email details
"""
message = draft_email_data.get("message", {})
payload = message.get("payload", {})
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
body_data = _get_email_plain_text_body(payload)
return {
"id": draft_email_data.get("id", ""),
"thread_id": draft_email_data.get("threadId", ""),
"from": headers.get("from", ""),
"date": headers.get("internaldate", ""),
"subject": headers.get("subject", ""),
"body": _clean_email_body(body_data) if body_data else "",
}
def _clean_email_body(body: str | None) -> str:
"""
Remove HTML tags and clean up email body text while preserving most content.
Args:
body (str): The raw email body text.
Returns:
str: Cleaned email body text.
"""
if not body:
return ""
try:
# Remove HTML tags using BeautifulSoup
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator=" ")
# Clean up the text
cleaned_text = _clean_text(text)
return cleaned_text.strip()
except Exception:
logger.exception("Error cleaning email body")
return body
def _get_email_plain_text_body(payload: dict[str, Any]) -> str | None:
"""
Extract email body from payload, handling 'multipart/alternative' parts.
Args:
payload (Dict[str, Any]): Email payload data.
Returns:
str | None: Decoded email body or None if not found.
"""
# Direct body extraction
if "body" in payload and payload["body"].get("data"):
return _clean_email_body(urlsafe_b64decode(payload["body"]["data"]).decode())
# Handle multipart and alternative parts
return _clean_email_body(_extract_plain_body(payload.get("parts", [])))
def _extract_plain_body(parts: list) -> str | None:
"""
Recursively extract the email body from parts, handling both plain text and HTML.
Args:
parts (List[Dict[str, Any]]): List of email parts.
Returns:
str | None: Decoded and cleaned email body or None if not found.
"""
for part in parts:
mime_type = part.get("mimeType")
if mime_type == "text/plain" and "data" in part.get("body", {}):
return urlsafe_b64decode(part["body"]["data"]).decode()
elif mime_type.startswith("multipart/"):
subparts = part.get("parts", [])
body = _extract_plain_body(subparts)
if body:
return body
return _extract_html_body(parts)
def _extract_html_body(parts: list) -> str | None:
"""
Recursively extract the email body from parts, handling only HTML.
Args:
parts (List[Dict[str, Any]]): List of email parts.
Returns:
str | None: Decoded and cleaned email body or None if not found.
"""
for part in parts:
mime_type = part.get("mimeType")
if mime_type == "text/html" and "data" in part.get("body", {}):
html_content = urlsafe_b64decode(part["body"]["data"]).decode()
return html_content
elif mime_type.startswith("multipart/"):
subparts = part.get("parts", [])
body = _extract_html_body(subparts)
if body:
return body
return None
def _clean_text(text: str) -> str:
"""
Clean up the text while preserving most content.
Args:
text (str): The input text.
Returns:
str: Cleaned text.
"""
# Replace multiple newlines with a single newline
text = re.sub(r"\n+", "\n", text)
# Replace multiple spaces with a single space
text = re.sub(r"\s+", " ", text)
# Remove leading/trailing whitespace from each line
text = "\n".join(line.strip() for line in text.split("\n"))
return text
def parse_plain_text_email(email_data: dict[str, Any]) -> dict[str, Any]:
"""
Parse email data and extract relevant information.
Only returns the plain text body.
Args:
email_data (dict[str, Any]): Raw email data from Gmail API.
Returns:
dict[str, str]: Parsed email details
"""
payload = email_data.get("payload", {})
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
body_data = _get_email_plain_text_body(payload)
email_details = {
"id": email_data.get("id", ""),
"thread_id": email_data.get("threadId", ""),
"label_ids": email_data.get("labelIds", []),
"history_id": email_data.get("historyId", ""),
"snippet": email_data.get("snippet", ""),
"to": headers.get("to", ""),
"cc": headers.get("cc", ""),
"from": headers.get("from", ""),
"reply_to": headers.get("reply-to", ""),
"in_reply_to": headers.get("in-reply-to", ""),
"references": headers.get("references", ""),
"header_message_id": headers.get("message-id", ""),
"date": headers.get("date", ""),
"subject": headers.get("subject", ""),
"body": body_data or "",
}
return email_details
def build_reply_body(body: str, replying_to: dict[str, Any]) -> str:
attribution = f"On {replying_to['date']}, {replying_to['from']} wrote:"
lines = replying_to["plain_text_body"].split("\n")
quoted_plain = "\n".join([f"> {line}" for line in lines])
return f"{body}\n\n{attribution}\n\n{quoted_plain}"
def parse_multipart_email(email_data: dict[str, Any]) -> dict[str, Any]:
"""
Parse email data and extract relevant information.
Returns the plain text and HTML body along with the images.
Args:
email_data (Dict[str, Any]): Raw email data from Gmail API.
Returns:
dict[str, Any]: Parsed email details
"""
payload = email_data.get("payload", {})
headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
# Extract different parts of the email
plain_text_body = _get_email_plain_text_body(payload)
html_body = _get_email_html_body(payload)
email_details = {
"id": email_data.get("id", ""),
"thread_id": email_data.get("threadId", ""),
"label_ids": email_data.get("labelIds", []),
"history_id": email_data.get("historyId", ""),
"snippet": email_data.get("snippet", ""),
"to": headers.get("to", ""),
"cc": headers.get("cc", ""),
"from": headers.get("from", ""),
"reply_to": headers.get("reply-to", ""),
"in_reply_to": headers.get("in-reply-to", ""),
"references": headers.get("references", ""),
"header_message_id": headers.get("message-id", ""),
"date": headers.get("date", ""),
"subject": headers.get("subject", ""),
"plain_text_body": plain_text_body or _clean_email_body(html_body),
"html_body": html_body or "",
}
return email_details
def _get_email_html_body(payload: dict[str, Any]) -> str | None:
"""
Extract email html body from payload, handling 'multipart/alternative' parts.
Args:
payload (Dict[str, Any]): Email payload data.
Returns:
str | None: Decoded email body or None if not found.
"""
# Direct body extraction
if "body" in payload and payload["body"].get("data"):
return urlsafe_b64decode(payload["body"]["data"]).decode()
# Handle multipart and alternative parts
return _extract_html_body(payload.get("parts", []))

View file

@ -0,0 +1,431 @@
import json
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
SimilarityCritic,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_gmail
from arcade_gmail.enums import GmailReplyToWhom
from arcade_gmail.tools import (
get_thread,
list_emails_by_header,
list_threads,
reply_to_email,
search_threads,
send_email,
write_draft_reply_email,
)
from arcade_gmail.utils import DateRange
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_gmail)
@tool_eval()
def gmail_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Gmail tools."""
suite = EvalSuite(
name="Gmail Tools Evaluation",
system_message="You are an AI assistant that can send and manage emails using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Send email to user with clear username",
user_message="Send a email to johndoe@example.com saying 'Hello, can we meet at 3 PM?'. CC his boss janedoe@example.com",
expected_tool_calls=[
ExpectedToolCall(
func=send_email,
args={
"subject": "Meeting Request",
"body": "Hello, can we meet at 3 PM?",
"recipient": "johndoe@example.com",
"cc": ["janedoe@example.com"],
"bcc": None,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=0.125),
SimilarityCritic(critic_field="body", weight=0.25),
BinaryCritic(critic_field="recipient", weight=0.25),
BinaryCritic(critic_field="cc", weight=0.25),
BinaryCritic(critic_field="bcc", weight=0.125),
],
)
suite.add_case(
name="Simple list threads",
user_message="Get 42 threads like right now i even wanna see the ones in my trash",
expected_tool_calls=[
ExpectedToolCall(
func=list_threads,
args={"max_results": 42, "include_spam_trash": True},
)
],
critics=[
BinaryCritic(critic_field="max_results", weight=0.5),
BinaryCritic(critic_field="include_spam_trash", weight=0.5),
],
)
history = [
{"role": "user", "content": "list 1 thread"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_X8V5Hw9iJ3wfB8WMZf8omAMi",
"type": "function",
"function": {"name": "Google_ListThreads", "arguments": '{"max_results":1}'},
}
],
},
{
"role": "tool",
"content": '{"next_page_token":"10321400718999360131","num_threads":1,"threads":[{"historyId":"61691","id":"1934a8f8deccb749","snippet":"Hi Joe, I hope this email finds you well. Thank you for being a part of our community."}]}',
"tool_call_id": "call_X8V5Hw9iJ3wfB8WMZf8omAMi",
"name": "Google_ListThreads",
},
{
"role": "assistant",
"content": "Here is one email thread:\n\n- **Snippet:** Hi Joe, I hope this email finds you well. Thank you for being a part of our community.\n- **Thread ID:** 1934a8f8deccb749\n- **History ID:** 61691",
},
]
suite.add_case(
name="List threads with history",
user_message="Get the next 5 threads",
additional_messages=history,
expected_tool_calls=[
ExpectedToolCall(
func=list_threads,
args={
"max_results": 5,
"page_token": "10321400718999360131",
},
)
],
critics=[
BinaryCritic(critic_field="max_results", weight=0.2),
BinaryCritic(critic_field="page_token", weight=0.8),
],
)
suite.add_case(
name="Search threads",
user_message="Search for threads from johndoe@example.com to janedoe@example.com about that talk about 'Arcade AI' from yesterday",
expected_tool_calls=[
ExpectedToolCall(
func=search_threads,
args={
"sender": "johndoe@example.com",
"recipient": "janedoe@example.com",
"body": "Arcade AI",
"date_range": DateRange.YESTERDAY,
},
)
],
critics=[
BinaryCritic(critic_field="sender", weight=0.25),
BinaryCritic(critic_field="recipient", weight=0.25),
SimilarityCritic(critic_field="body", weight=0.25),
BinaryCritic(critic_field="date_range", weight=0.25),
],
)
suite.add_case(
name="Get a thread by ID",
user_message="Get the thread r-124325435467568867667878874565464564563523424323524235242412",
expected_tool_calls=[
ExpectedToolCall(
func=get_thread,
args={
"thread_id": "r-124325435467568867667878874565464564563523424323524235242412",
},
)
],
critics=[
BinaryCritic(critic_field="thread_id", weight=1.0),
],
)
return suite
@tool_eval()
def gmail_reply_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Gmail reply tools."""
suite = EvalSuite(
name="Gmail Reply Tools Evaluation",
system_message="You are an AI assistant that can send and manage emails using the provided tools.",
catalog=catalog,
rubric=rubric,
)
email_history = [
{"role": "user", "content": "get the latest emails I received from johndoe@gmail.com"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_jowMD7aB9sVPClOfvNof7Llu",
"type": "function",
"function": {
"name": "Google_ListEmailsByHeader",
"arguments": json.dumps({
"sender": "johndoe@gmail.com",
"max_results": 5,
}),
},
}
],
},
{
"role": "tool",
"content": json.dumps({
"emails": [
{
"body": "test 1",
"cc": "",
"date": "Tue, 11 Feb 2025 11:33:08 -0300",
"from": "John Doe <johndoe@gmail.com>",
"header_message_id": "<cierty475cty7245yxq@mail.gmail.com>",
"history_id": "123456",
"id": "q34759q435nv",
"in_reply_to": "",
"label_ids": ["INBOX"],
"references": "",
"reply_to": "",
"snippet": "test 1",
"subject": "test 1",
"thread_id": "345y6v3596",
"to": "myself@gmail.com",
},
{
"body": "test 2",
"cc": "",
"date": "Mon, 20 Jan 2025 13:04:42 -0800",
"from": "John Doe <johndoe@gmail.com>",
"header_message_id": "<28745ytvw8745ct4@mail.gmail.com>",
"history_id": "3456758",
"id": "9475tvy24578yx",
"in_reply_to": "",
"label_ids": [],
"references": "",
"reply_to": "",
"snippet": "test 2",
"subject": "test 2",
"thread_id": "249576v3496",
"to": "myself@gmail.com",
},
]
}),
"tool_call_id": "call_jowMD7aB9sVPClOfvNof7Llu",
"name": "Google_ListEmailsByHeader",
},
{
"role": "assistant",
"content": "Here are the latest emails you received from johndoe@gmail.com:\n\n1. **Subject**: test 1\n - **Date**: Tue, 11 Feb 2025 11:33:08 -0300\n - **Snippet**: test 1\n\n2. **Subject**: test 2\n - **Date**: Mon, 20 Jan 2025 13:04:42 -0800\n - **Snippet**: test 2\n\nIf you need further details from any specific email, let me know!",
},
]
suite.add_case(
name="Reply to an email",
user_message="Reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well'",
expected_tool_calls=[
ExpectedToolCall(
func=reply_to_email,
args={
"reply_to_message_id": "9475tvy24578yx",
"body": "tested and working well",
"reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=1 / 7),
SimilarityCritic(critic_field="body", weight=1 / 7),
BinaryCritic(critic_field="recipient", weight=1 / 7),
BinaryCritic(critic_field="cc", weight=1 / 7),
BinaryCritic(critic_field="bcc", weight=1 / 7),
BinaryCritic(critic_field="reply_to_whom", weight=1 / 7),
BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7),
],
additional_messages=email_history,
)
suite.add_case(
name="Reply to an email with every recipient",
user_message="Reply to every recipient in the email from johndoe@example.com about 'test 2' saying 'tested and working well'",
expected_tool_calls=[
ExpectedToolCall(
func=reply_to_email,
args={
"reply_to_message_id": "9475tvy24578yx",
"body": "tested and working well",
"reply_to_whom": GmailReplyToWhom.EVERY_RECIPIENT.value,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=1 / 7),
SimilarityCritic(critic_field="body", weight=1 / 7),
BinaryCritic(critic_field="recipient", weight=1 / 7),
BinaryCritic(critic_field="cc", weight=1 / 7),
BinaryCritic(critic_field="bcc", weight=1 / 7),
BinaryCritic(critic_field="reply_to_whom", weight=1 / 7),
BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7),
],
additional_messages=email_history,
)
suite.add_case(
name="Reply to an email with bcc",
user_message="Reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well' and send it to janedoe@example.com as bcc as well",
expected_tool_calls=[
ExpectedToolCall(
func=reply_to_email,
args={
"reply_to_message_id": "9475tvy24578yx",
"body": "tested and working well",
"bcc": ["janedoe@example.com"],
"reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=1 / 7),
SimilarityCritic(critic_field="body", weight=1 / 7),
BinaryCritic(critic_field="recipient", weight=1 / 7),
BinaryCritic(critic_field="cc", weight=1 / 7),
BinaryCritic(critic_field="bcc", weight=1 / 7),
BinaryCritic(critic_field="reply_to_whom", weight=1 / 7),
BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7),
],
additional_messages=email_history,
)
suite.add_case(
name="Write draft reply",
user_message="Write a draft reply to the email from johndoe@example.com about 'test 2' saying 'tested and working well'",
expected_tool_calls=[
ExpectedToolCall(
func=write_draft_reply_email,
args={
"reply_to_message_id": "9475tvy24578yx",
"body": "tested and working well",
"reply_to_whom": GmailReplyToWhom.ONLY_THE_SENDER.value,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=1 / 7),
SimilarityCritic(critic_field="body", weight=1 / 7),
BinaryCritic(critic_field="recipient", weight=1 / 7),
BinaryCritic(critic_field="cc", weight=1 / 7),
BinaryCritic(critic_field="bcc", weight=1 / 7),
BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7),
BinaryCritic(critic_field="reply_to_whom", weight=1 / 7),
],
additional_messages=email_history,
)
suite.add_case(
name="Write draft reply to every recipient",
user_message="Write a draft reply to every recipient in the email from johndoe@example.com about 'test 2' saying 'tested and working well'",
expected_tool_calls=[
ExpectedToolCall(
func=write_draft_reply_email,
args={
"reply_to_message_id": "9475tvy24578yx",
"body": "tested and working well",
"reply_to_whom": GmailReplyToWhom.EVERY_RECIPIENT.value,
},
)
],
critics=[
SimilarityCritic(critic_field="subject", weight=1 / 7),
SimilarityCritic(critic_field="body", weight=1 / 7),
BinaryCritic(critic_field="recipient", weight=1 / 7),
BinaryCritic(critic_field="cc", weight=1 / 7),
BinaryCritic(critic_field="bcc", weight=1 / 7),
BinaryCritic(critic_field="reply_to_whom", weight=0.125),
BinaryCritic(critic_field="reply_to_message_id", weight=1 / 7),
],
additional_messages=email_history,
)
return suite
@tool_eval()
def gmail_list_emails_by_header_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Gmail tools."""
suite = EvalSuite(
name="Gmail list_emails_by_header tool evaluation",
system_message="You are an AI assistant that can send and manage emails using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="List emails by header using date-range",
user_message="List all emails from johndoe@example.com to janedoe@example.com about 'Arcade AI' from yesterday",
expected_tool_calls=[
ExpectedToolCall(
func=list_emails_by_header,
args={
"sender": "johndoe@example.com",
"recipient": "janedoe@example.com",
"subject": "Arcade AI",
"date_range": DateRange.YESTERDAY.value,
},
)
],
critics=[
BinaryCritic(critic_field="sender", weight=1 / 4),
BinaryCritic(critic_field="recipient", weight=1 / 4),
SimilarityCritic(critic_field="subject", weight=1 / 4),
BinaryCritic(critic_field="date_range", weight=1 / 4),
],
)
suite.add_case(
name="List emails by header using date-range",
user_message="List all emails from johndoe@example.com to janedoe@example.com about 'Arcade AI' from the last month",
expected_tool_calls=[
ExpectedToolCall(
func=list_emails_by_header,
args={
"sender": "johndoe@example.com",
"recipient": "janedoe@example.com",
"subject": "Arcade AI",
"date_range": DateRange.LAST_MONTH.value,
},
)
],
critics=[
BinaryCritic(critic_field="sender", weight=1 / 4),
BinaryCritic(critic_field="recipient", weight=1 / 4),
SimilarityCritic(critic_field="subject", weight=1 / 4),
BinaryCritic(critic_field="date_range", weight=1 / 4),
],
)
return suite

View file

@ -0,0 +1,64 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_gmail"
version = "2.0.0"
description = "Arcade.dev LLM tools for Gmail"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"beautifulsoup4>=4.10.0,<5.0.0",
"google-api-core>=2.19.1,<3.0.0",
"google-api-python-client>=2.137.0,<3.0.0",
"google-auth>=2.32.0,<3.0.0",
"google-auth-httplib2>=0.2.0,<1.0.0",
"googleapis-common-protos>=1.63.2,<2.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0rc1,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-mock>=3.11.1,<3.12.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = { path = "../../", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
[tool.mypy]
files = [ "arcade_gmail/**/*.py",]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = [ "tests",]
[tool.coverage.report]
skip_empty = true
[tool.hatch.build.targets.wheel]
packages = [ "arcade_gmail",]

View file

View file

@ -0,0 +1,951 @@
from base64 import urlsafe_b64encode
from email.message import EmailMessage
from unittest.mock import MagicMock, patch
import pytest
from arcade_tdk import ToolAuthorizationContext, ToolContext
from arcade_tdk.errors import ToolExecutionError
from googleapiclient.errors import HttpError
from arcade_gmail.enums import GmailReplyToWhom
from arcade_gmail.tools import (
delete_draft_email,
get_thread,
list_draft_emails,
list_emails,
list_emails_by_header,
list_threads,
reply_to_email,
search_threads,
send_draft_email,
send_email,
trash_email,
update_draft_email,
write_draft_email,
)
from arcade_gmail.utils import (
build_reply_body,
parse_draft_email,
parse_multipart_email,
parse_plain_text_email,
)
@pytest.fixture
def mock_context():
mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106
return ToolContext(authorization=mock_auth)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_send_email(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Test happy path
result = await send_email(
context=mock_context,
subject="Test Subject",
body="Test Body",
recipient="test@example.com",
)
assert isinstance(result, dict)
assert "id" in result
assert "thread_id" in result
assert "subject" in result
assert "body" in result
# Test http error
mock_service.users().messages().send().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid recipient"}}',
)
with pytest.raises(ToolExecutionError):
await send_email(
context=mock_context,
subject="Test Subject",
body="Test Body",
recipient="invalid@example.com",
)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_write_draft_email(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Test happy path
result = await write_draft_email(
context=mock_context,
subject="Test Draft Subject",
body="Test Draft Body",
recipient="draft@example.com",
)
assert isinstance(result, dict)
assert "id" in result
assert "thread_id" in result
assert "subject" in result
assert "body" in result
# Test http error
mock_service.users().drafts().create().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await write_draft_email(
context=mock_context,
subject="Test Draft Subject",
body="Test Draft Body",
recipient="draft@example.com",
)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_update_draft_email(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Test happy path
result = await update_draft_email(
context=mock_context,
draft_email_id="draft123",
subject="Updated Subject",
body="Updated Body",
recipient="updated@example.com",
)
assert isinstance(result, dict)
assert "id" in result
assert "thread_id" in result
assert "subject" in result
assert "body" in result
# Test http error
mock_service.users().drafts().update().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Draft not found"}}',
)
with pytest.raises(ToolExecutionError):
await update_draft_email(
context=mock_context,
draft_email_id="nonexistent_draft",
subject="Updated Subject",
body="Updated Body",
recipient="updated@example.com",
)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_send_draft_email(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Test happy path
result = await send_draft_email(context=mock_context, email_id="draft456")
assert isinstance(result, dict)
assert "id" in result
assert "thread_id" in result
assert "subject" in result
assert "body" in result
# Test http error
mock_service.users().drafts().send().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Draft not found"}}',
)
with pytest.raises(ToolExecutionError):
await send_draft_email(context=mock_context, email_id="nonexistent_draft")
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_delete_draft_email(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Test happy path
result = await delete_draft_email(context=mock_context, draft_email_id="draft789")
assert "Draft email with ID" in result
assert "deleted successfully" in result
# Test http error
mock_service.users().drafts().delete().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Draft not found"}}',
)
with pytest.raises(ToolExecutionError):
await delete_draft_email(context=mock_context, draft_email_id="nonexistent_draft")
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
@patch("arcade_gmail.tools.gmail.parse_draft_email")
async def test_get_draft_emails(mock_parse_draft_email, mock_build, mock_context):
# Setup test data
mock_drafts_list_response = {
"drafts": [
{
"id": "r9999999999999999999",
"message": {"id": "0000000000000000", "threadId": "0000000000000000"},
}
],
"resultSizeEstimate": 1,
}
mock_drafts_get_response = {
"id": "r9999999999999999999",
"message": {
"id": "0000000000000000",
"threadId": "0000000000000000",
"labelIds": ["DRAFT"],
"snippet": "Hello! This is a test. Best regards, John",
"payload": {
"partId": "",
"mimeType": "text/plain",
"filename": "",
"headers": [
{"name": "to", "value": "test@arcade-ai.com"},
{"name": "subject", "value": "New Draft"},
{"name": "Date", "value": "Mon, 16 Sep 2024 13:02:10 -0400"},
{"name": "From", "value": "john-doe@arcade-ai.com"},
],
"body": {
"size": 41,
"data": "SGVsbG8hIFRoaXMgaXMgYSB0ZXN0LgoKQmVzdCByZWdhcmRzLApCb2I=",
},
},
"sizeEstimate": 453,
"historyId": "7061",
"internalDate": "1726506130000",
},
}
# Setup mocking
mock_service = MagicMock()
mock_build.return_value = mock_service
# Mock the response from the Gmail list drafts API
mock_service.users().drafts().list().execute.return_value = mock_drafts_list_response
# Mock the response from the Gmail get drafts API
mock_service.users().drafts().get().execute.return_value = mock_drafts_get_response
# Mock the parse_draft_email function since parse_draft_email doesn't accept object of type MagicMock
mock_parse_draft_email.return_value = parse_draft_email(mock_drafts_get_response)
# Test happy path
result = await list_draft_emails(context=mock_context, n_drafts=2)
assert isinstance(result, dict)
assert "emails" in result
assert len(result["emails"]) == 1
assert all("id" in draft and "subject" in draft for draft in result["emails"])
# Test http error
mock_service.users().drafts().list().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await list_draft_emails(context=mock_context, n_drafts=2)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
@patch("arcade_gmail.tools.gmail.parse_plain_text_email")
async def test_search_emails_by_header(mock_parse_plain_text_email, mock_build, mock_context):
# Setup test data
mock_messages_list_response = {
"messages": [
{"id": "191fbc8ddce0f433", "threadId": "191fbc8ddce0f433"},
{"id": "191fbc0ea11efa90", "threadId": "191fbc0ea11efa90"},
],
"nextPageToken": "00755945214480102915",
"resultSizeEstimate": 201,
}
mock_messages_get_response = {
"id": "191f2cf4d24bf23d",
"threadId": "191f2cf4d24bf23d",
"labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "INBOX"],
"snippet": "Hey User, Your personal access token (classic) &quot;ArcadeAI&quot; with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,",
"payload": {
"partId": "",
"mimeType": "text/plain",
"filename": "",
"headers": [
{"name": "Delivered-To", "value": "example@arcade-ai.com"},
{"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"},
{"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"},
{"name": "To", "value": "example@arcade-ai.com"},
{
"name": "Subject",
"value": "[GitHub] Your personal access token (classic) has expired",
},
],
"body": {
"size": 605,
"data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=",
},
},
"sizeEstimate": 4512,
"historyId": "5508",
"internalDate": "1726355557000",
}
# Setup mocking
mock_service = MagicMock()
mock_build.return_value = mock_service
# Mock the response from the Gmail list messages API
mock_service.users().messages().list().execute.return_value = mock_messages_list_response
# Mock the response from the Gmail get messages API
mock_service.users().messages().get().execute.return_value = mock_messages_get_response
# Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock
mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response)
# Test happy path
result = await list_emails_by_header(
context=mock_context, sender="noreply@github.com", max_results=2
)
assert isinstance(result, dict)
assert "emails" in result
assert len(result["emails"]) == 2
assert all("id" in email and "subject" in email for email in result["emails"])
# Test http error
mock_service.users().messages().list().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await list_emails_by_header(
context=mock_context, sender="noreply@github.com", max_results=2
)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
@patch("arcade_gmail.tools.gmail.parse_plain_text_email")
async def test_get_emails(mock_parse_plain_text_email, mock_build, mock_context):
# Setup test data
mock_messages_list_response = {
"messages": [
{"id": "191fbc8ddce0f433", "threadId": "191fbc8ddce0f433"},
],
"nextPageToken": "00755945214480102915",
"resultSizeEstimate": 1,
}
mock_messages_get_response = {
"id": "191f2cf4d24bf23d",
"threadId": "191f2cf4d24bf23d",
"labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "INBOX"],
"snippet": "Hey User, Your personal access token (classic) &quot;ArcadeAI&quot; with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,",
"payload": {
"partId": "",
"mimeType": "text/plain",
"filename": "",
"headers": [
{"name": "Delivered-To", "value": "example@arcade-ai.com"},
{"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"},
{"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"},
{"name": "To", "value": "example@arcade-ai.com"},
{
"name": "Subject",
"value": "[GitHub] Your personal access token (classic) has expired",
},
],
"body": {
"size": 605,
"data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=",
},
},
"sizeEstimate": 4512,
"historyId": "5508",
"internalDate": "1726355557000",
}
# Setup mocking
mock_service = MagicMock()
mock_build.return_value = mock_service
# Mock the response from the Gmail list messages API
mock_service.users().messages().list().execute.return_value = mock_messages_list_response
# Mock the Gmail get messages API
mock_service.users().messages().get().execute.return_value = mock_messages_get_response
# Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock
mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response)
# Test happy path
result = await list_emails(context=mock_context, n_emails=1)
assert isinstance(result, dict)
assert "emails" in result
assert len(result["emails"]) == 1
assert "id" in result["emails"][0]
assert "subject" in result["emails"][0]
assert "date" in result["emails"][0]
assert "body" in result["emails"][0]
# Test http error
mock_service.users().messages().list().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await list_emails(context=mock_context, n_emails=1)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_trash_email(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Test happy path
email_id = "123456"
result = await trash_email(context=mock_context, email_id=email_id)
assert isinstance(result, dict)
assert "id" in result
assert "thread_id" in result
assert "subject" in result
assert "body" in result
# Test http error
mock_service.users().messages().trash().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Email not found"}}',
)
with pytest.raises(ToolExecutionError):
await trash_email(context=mock_context, email_id="nonexistent_email")
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_search_threads(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Setup mock response data
mock_threads_list_response = {
"threads": [
{
"id": "thread1",
"snippet": "Thread snippet 1",
},
{
"id": "thread2",
"snippet": "Thread snippet 2",
},
],
"nextPageToken": "next_token_123",
"resultSizeEstimate": 2,
}
# Mock the Gmail API threads().list() method
mock_service.users().threads().list().execute.return_value = mock_threads_list_response
# Test happy path
result = await search_threads(
context=mock_context,
sender="test@example.com",
max_results=2,
)
assert isinstance(result, dict)
assert "threads" in result
assert len(result["threads"]) == 2
assert result["threads"][0]["id"] == "thread1"
assert "next_page_token" in result
# Test error handling
mock_service.users().threads().list().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await search_threads(
context=mock_context,
sender="test@example.com",
max_results=2,
)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_list_threads(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Setup mock response data
mock_threads_list_response = {
"threads": [
{
"id": "thread1",
"snippet": "Thread snippet 1",
},
{
"id": "thread2",
"snippet": "Thread snippet 2",
},
],
"nextPageToken": "next_token_123",
"resultSizeEstimate": 2,
}
# Mock the Gmail API threads().list() method
mock_service.users().threads().list().execute.return_value = mock_threads_list_response
# Test happy path
result = await list_threads(
context=mock_context,
max_results=2,
)
assert isinstance(result, dict)
assert "threads" in result
assert len(result["threads"]) == 2
assert result["threads"][0]["id"] == "thread1"
assert "next_page_token" in result
# Test error handling
mock_service.users().threads().list().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await list_threads(
context=mock_context,
max_results=2,
)
@pytest.mark.asyncio
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_get_thread(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Setup mock response data
mock_thread_get_response = {
"id": "thread1",
"messages": [
{
"id": "message1",
"snippet": "Message snippet 1",
},
{
"id": "message2",
"snippet": "Message snippet 2",
},
],
}
# Mock the Gmail API threads().get() method
mock_service.users().threads().get().execute.return_value = mock_thread_get_response
# Test happy path
result = await get_thread(
context=mock_context,
thread_id="thread1",
)
assert isinstance(result, dict)
assert "id" in result
assert result["id"] == "thread1"
assert "messages" in result
assert len(result["messages"]) == 2
assert result["messages"][0]["id"] == "message1"
# Test error handling
mock_service.users().threads().get().execute.side_effect = HttpError(
resp=MagicMock(status=404),
content=b'{"error": {"message": "Thread not found"}}',
)
with pytest.raises(ToolExecutionError):
await get_thread(
context=mock_context,
thread_id="invalid_thread",
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"reply_to_whom, expected_to, expected_cc",
[
(
GmailReplyToWhom.EVERY_RECIPIENT,
"sender@example.com, to1@example.com, to2@example.com",
"cc1@example.com, cc2@example.com",
),
(
GmailReplyToWhom.ONLY_THE_SENDER,
"sender@example.com",
"",
),
],
)
@patch("arcade_gmail.tools.gmail._build_gmail_service")
async def test_reply_to_email(mock_build, reply_to_whom, expected_to, expected_cc, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
original_message = {
"id": "id123456",
"threadId": "thread123456",
"payload": {
"headers": [
{"name": "Message-ID", "value": "id123456"},
{"name": "Subject", "value": "test"},
{"name": "From", "value": "sender@example.com"},
{"name": "To", "value": "to1@example.com, to2@example.com, test@example.com"},
{"name": "Cc", "value": "cc1@example.com, cc2@example.com"},
{"name": "References", "value": "thread123456"},
],
},
}
mock_service.users().getProfile().execute.return_value = {"emailAddress": "test@example.com"}
mock_service.users().messages().get().execute.return_value = original_message
result = await reply_to_email(
context=mock_context,
body="test",
reply_to_message_id="id123456",
reply_to_whom=reply_to_whom,
)
assert isinstance(result, dict)
assert "url" in result
replying_to = parse_multipart_email(original_message)
expected_body = build_reply_body("test", replying_to)
expected_message = EmailMessage()
expected_message.set_content(expected_body)
expected_message["To"] = expected_to
expected_message["Subject"] = "Re: test"
if expected_cc:
expected_message["Cc"] = expected_cc
expected_message["In-Reply-To"] = "id123456"
expected_message["References"] = "id123456, thread123456"
mock_service.users().messages().send.assert_called_once_with(
userId="me",
body={
"raw": urlsafe_b64encode(expected_message.as_bytes()).decode(),
"threadId": "thread123456",
},
)
def test_parse_multipart_email_full():
"""
Test parsing a multipart email with both plain text and HTML bodies.
"""
email_data = {
"id": "email123",
"threadId": "thread123",
"labelIds": ["INBOX", "UNREAD"],
"historyId": "history123",
"snippet": "This is a test email.",
"payload": {
"headers": [
{"name": "To", "value": "recipient@example.com"},
{"name": "From", "value": "sender@example.com"},
{"name": "Subject", "value": "Test Email"},
{"name": "Date", "value": "Mon, 1 Jan 2024 10:00:00 -0000"},
],
"body": {"size": 100, "data": "VGhpcyBpcyBhIHRlc3QgZW1haWwu"},
},
}
with (
patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain,
patch("arcade_gmail.utils._get_email_html_body") as mock_html,
patch("arcade_gmail.utils._clean_email_body") as mock_clean,
):
# Mock the helper functions
mock_plain.return_value = "This is a test email."
mock_html.return_value = "<p>This is a test email.</p>"
mock_clean.return_value = "This is a test email."
result = parse_multipart_email(email_data)
assert result["id"] == "email123"
assert result["thread_id"] == "thread123"
assert result["label_ids"] == ["INBOX", "UNREAD"]
assert result["snippet"] == "This is a test email."
assert result["to"] == "recipient@example.com"
assert result["from"] == "sender@example.com"
assert result["subject"] == "Test Email"
assert result["date"] == "Mon, 1 Jan 2024 10:00:00 -0000"
assert result["plain_text_body"] == "This is a test email."
assert result["html_body"] == "<p>This is a test email.</p>"
def test_parse_multipart_email_plain_only():
"""
Test parsing an email with only a plain text body.
"""
email_data = {
"id": "email456",
"threadId": "thread456",
"labelIds": ["INBOX"],
"historyId": "history456",
"snippet": "Plain text only email.",
"payload": {
"headers": [
{"name": "To", "value": "recipient2@example.com"},
{"name": "From", "value": "sender2@example.com"},
{"name": "Subject", "value": "Plain Text Email"},
{"name": "Date", "value": "Tue, 2 Feb 2024 11:00:00 -0000"},
],
"body": {"size": 150, "data": "UGxhaW4gdGV4dCBvbmx5IGVtYWlsLg=="},
},
}
with (
patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain,
patch("arcade_gmail.utils._get_email_html_body") as mock_html,
patch("arcade_gmail.utils._clean_email_body") as mock_clean,
):
# Mock the helper functions
mock_plain.return_value = "Plain text only email."
mock_html.return_value = None
mock_clean.return_value = "Plain text only email."
result = parse_multipart_email(email_data)
assert result["id"] == "email456"
assert result["thread_id"] == "thread456"
assert result["label_ids"] == ["INBOX"]
assert result["snippet"] == "Plain text only email."
assert result["to"] == "recipient2@example.com"
assert result["from"] == "sender2@example.com"
assert result["subject"] == "Plain Text Email"
assert result["date"] == "Tue, 2 Feb 2024 11:00:00 -0000"
assert result["plain_text_body"] == "Plain text only email."
assert result["html_body"] == ""
def test_parse_multipart_email_html_only():
"""
Test parsing an email with only an HTML body.
"""
email_data = {
"id": "email789",
"threadId": "thread789",
"labelIds": ["SENT"],
"historyId": "history789",
"snippet": "HTML only email.",
"payload": {
"headers": [
{"name": "To", "value": "recipient3@example.com"},
{"name": "From", "value": "sender3@example.com"},
{"name": "Subject", "value": "HTML Email"},
{"name": "Date", "value": "Wed, 3 Mar 2024 12:00:00 -0000"},
],
"body": {"size": 200, "data": "PGh0bWw+VGhpcyBpcyBIVE1MIGVtYWlsLjwvaHRtbD4="},
},
}
with (
patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain,
patch("arcade_gmail.utils._get_email_html_body") as mock_html,
patch("arcade_gmail.utils._clean_email_body") as mock_clean,
):
# Mock the helper functions
mock_plain.return_value = None
mock_html.return_value = "<html>This is HTML email.</html>"
mock_clean.return_value = "This is HTML email."
result = parse_multipart_email(email_data)
assert result["id"] == "email789"
assert result["thread_id"] == "thread789"
assert result["label_ids"] == ["SENT"]
assert result["snippet"] == "HTML only email."
assert result["to"] == "recipient3@example.com"
assert result["from"] == "sender3@example.com"
assert result["subject"] == "HTML Email"
assert result["date"] == "Wed, 3 Mar 2024 12:00:00 -0000"
assert result["plain_text_body"] == "This is HTML email."
assert result["html_body"] == "<html>This is HTML email.</html>"
def test_parse_multipart_email_missing_payload():
"""
Test parsing an email with missing payload.
"""
email_data = {
"id": "email000",
"threadId": "thread000",
"labelIds": ["INBOX"],
"historyId": "history000",
"snippet": "Missing payload email.",
# 'payload' key is missing
}
result = parse_multipart_email(email_data)
# Since payload is missing, headers and bodies should be default or empty
assert result["id"] == "email000"
assert result["thread_id"] == "thread000"
assert result["label_ids"] == ["INBOX"]
assert result["snippet"] == "Missing payload email."
assert result["to"] == ""
assert result["from"] == ""
assert result["subject"] == ""
assert result["date"] == ""
assert result["plain_text_body"] == ""
assert result["html_body"] == ""
def test_parse_multipart_email_missing_headers():
"""
Test parsing an email with missing headers in the payload.
"""
email_data = {
"id": "email111",
"threadId": "thread111",
"labelIds": ["INBOX"],
"historyId": "history111",
"snippet": "Missing headers email.",
"payload": {
# 'headers' key is missing
"body": {"size": 100, "data": "VGltZWw="}
},
}
with (
patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain,
patch("arcade_gmail.utils._get_email_html_body") as mock_html,
patch("arcade_gmail.utils._clean_email_body") as mock_clean,
):
# Mock the helper functions
mock_plain.return_value = "Timeel"
mock_html.return_value = "<p>Timeel</p>"
mock_clean.return_value = "Timeel"
result = parse_multipart_email(email_data)
assert result["id"] == "email111"
assert result["thread_id"] == "thread111"
assert result["label_ids"] == ["INBOX"]
assert result["snippet"] == "Missing headers email."
assert result["to"] == ""
assert result["from"] == ""
assert result["subject"] == ""
assert result["date"] == ""
assert result["plain_text_body"] == "Timeel"
assert result["html_body"] == "<p>Timeel</p>"
def test_parse_multipart_email_missing_fields():
"""
Test parsing an email with some missing fields in headers.
"""
email_data = {
"id": "email222",
"threadId": "thread222",
"labelIds": ["INBOX"],
"historyId": "history222",
"snippet": "Missing some headers.",
"payload": {
"headers": [
{"name": "From", "value": "sender4@example.com"},
{"name": "Subject", "value": "Partial Headers"},
# 'To' and 'Date' headers are missing
],
"body": {"size": 100, "data": "TWlzc2luZyBzb21lIGhlYWRlcnMu"},
},
}
with (
patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain,
patch("arcade_gmail.utils._get_email_html_body") as mock_html,
patch("arcade_gmail.utils._clean_email_body") as mock_clean,
):
# Mock the helper functions
mock_plain.return_value = "Missing some headers."
mock_html.return_value = None
mock_clean.return_value = "Missing some headers."
result = parse_multipart_email(email_data)
assert result["id"] == "email222"
assert result["thread_id"] == "thread222"
assert result["label_ids"] == ["INBOX"]
assert result["snippet"] == "Missing some headers."
assert result["to"] == ""
assert result["from"] == "sender4@example.com"
assert result["subject"] == "Partial Headers"
assert result["date"] == ""
assert result["plain_text_body"] == "Missing some headers."
assert result["html_body"] == ""
def test_parse_multipart_email_empty():
"""
Test parsing an empty email data.
"""
email_data = {}
result = parse_multipart_email(email_data)
assert result["id"] == ""
assert result["thread_id"] == ""
assert result["label_ids"] == []
assert result["snippet"] == ""
assert result["to"] == ""
assert result["from"] == ""
assert result["subject"] == ""
assert result["date"] == ""
assert result["plain_text_body"] == ""
assert result["html_body"] == ""
def test_parse_multipart_email_invalid_payload_structure():
"""
Test parsing an email with an invalid payload structure.
"""
email_data = {
"id": "email333",
"threadId": "thread333",
"labelIds": ["INBOX"],
"historyId": "history333",
"snippet": "Invalid payload structure.",
"payload": {
"headers": "This should be a list, not a string",
"body": {"size": 100, "data": "SW52YWxpZCBwYXlsb2Fk"},
},
}
with pytest.raises(TypeError):
parse_multipart_email(email_data)

View file

@ -0,0 +1,18 @@
files: ^.*/google_calendar/.*
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

View file

@ -0,0 +1,46 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

View file

@ -0,0 +1,55 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -0,0 +1,17 @@
from arcade_google_calendar.tools import (
create_event,
delete_event,
find_time_slots_when_everyone_is_free,
list_calendars,
list_events,
update_event,
)
__all__ = [
"create_event",
"delete_event",
"find_time_slots_when_everyone_is_free",
"list_calendars",
"list_events",
"update_event",
]

View file

@ -0,0 +1,14 @@
from enum import Enum
class EventVisibility(Enum):
DEFAULT = "default"
PUBLIC = "public"
PRIVATE = "private"
CONFIDENTIAL = "confidential"
class SendUpdatesOptions(Enum):
NONE = "none" # No notifications are sent
ALL = "all" # Notifications are sent to all guests
EXTERNAL_ONLY = "externalOnly" # Notifications are sent to non-Google Calendar guests only.

View file

@ -0,0 +1,17 @@
from arcade_google_calendar.tools.calendar import (
create_event,
delete_event,
find_time_slots_when_everyone_is_free,
list_calendars,
list_events,
update_event,
)
__all__ = [
"create_event",
"delete_event",
"find_time_slots_when_everyone_is_free",
"list_calendars",
"list_events",
"update_event",
]

View file

@ -0,0 +1,510 @@
import json
from datetime import datetime, timedelta
from typing import Annotated, Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Google
from arcade_tdk.errors import RetryableToolError
from googleapiclient.errors import HttpError
from arcade_google_calendar.enums import EventVisibility, SendUpdatesOptions
from arcade_google_calendar.utils import (
build_calendar_service,
build_oauth_service,
compute_free_time_intersection,
parse_datetime,
)
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
]
)
)
async def list_calendars(
context: ToolContext,
max_results: Annotated[
int, "The maximum number of calendars to return. Up to 250 calendars, defaults to 10."
] = 10,
show_deleted: Annotated[bool, "Whether to show deleted calendars. Defaults to False"] = False,
show_hidden: Annotated[bool, "Whether to show hidden calendars. Defaults to False"] = False,
next_page_token: Annotated[
str | None, "The token to retrieve the next page of calendars. Optional."
] = None,
) -> Annotated[dict, "A dictionary containing the calendars accessible by the end user"]:
"""
List all calendars accessible by the user.
"""
max_results = max(1, min(max_results, 250))
service = build_calendar_service(context.get_auth_token_or_empty())
calendars = (
service.calendarList()
.list(
pageToken=next_page_token,
showDeleted=show_deleted,
showHidden=show_hidden,
maxResults=max_results,
)
.execute()
)
items = calendars.get("items", [])
keys = ["description", "id", "summary", "timeZone"]
relevant_items = [{k: i.get(k) for k in keys if i.get(k)} for i in items]
return {
"next_page_token": calendars.get("nextPageToken"),
"num_calendars": len(relevant_items),
"calendars": relevant_items,
}
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
],
)
)
async def create_event(
context: ToolContext,
summary: Annotated[str, "The title of the event"],
start_datetime: Annotated[
str,
"The datetime when the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.",
],
end_datetime: Annotated[
str,
"The datetime when the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
],
calendar_id: Annotated[
str, "The ID of the calendar to create the event in, usually 'primary'."
] = "primary",
description: Annotated[str | None, "The description of the event"] = None,
location: Annotated[str | None, "The location of the event"] = None,
visibility: Annotated[EventVisibility, "The visibility of the event"] = EventVisibility.DEFAULT,
attendee_emails: Annotated[
list[str] | None,
"The list of attendee emails. Must be valid email addresses e.g., username@domain.com.",
] = None,
) -> Annotated[dict, "A dictionary containing the created event details"]:
"""Create a new event/meeting/sync/meetup in the specified calendar."""
service = build_calendar_service(context.get_auth_token_or_empty())
# Get the calendar's time zone
calendar = service.calendars().get(calendarId=calendar_id).execute()
time_zone = calendar["timeZone"]
# Parse datetime strings
start_dt = parse_datetime(start_datetime, time_zone)
end_dt = parse_datetime(end_datetime, time_zone)
event: dict[str, Any] = {
"summary": summary,
"description": description,
"location": location,
"start": {"dateTime": start_dt.isoformat(), "timeZone": time_zone},
"end": {"dateTime": end_dt.isoformat(), "timeZone": time_zone},
"visibility": visibility.value,
}
if attendee_emails:
event["attendees"] = [{"email": email} for email in attendee_emails]
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
return {"event": created_event}
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
],
)
)
async def list_events(
context: ToolContext,
min_end_datetime: Annotated[
str,
"Filter by events that end on or after this datetime in ISO 8601 format, "
"e.g., '2024-09-15T09:00:00'.",
],
max_start_datetime: Annotated[
str,
"Filter by events that start before this datetime in ISO 8601 format, "
"e.g., '2024-09-16T17:00:00'.",
],
calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary",
max_results: Annotated[int, "The maximum number of events to return"] = 10,
) -> Annotated[dict, "A dictionary containing the list of events"]:
"""
List events from the specified calendar within the given datetime range.
min_end_datetime serves as the lower bound (exclusive) for an event's end time.
max_start_datetime serves as the upper bound (exclusive) for an event's start time.
For example:
If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime
is set to 2024-09-16T17:00:00, the function will return events that:
1. End after 09:00 on September 15, 2024 (exclusive)
2. Start before 17:00 on September 16, 2024 (exclusive)
This means an event starting at 08:00 on September 15 and
ending at 10:00 on September 15 would be included, but an
event starting at 17:00 on September 16 would not be included.
"""
service = build_calendar_service(context.get_auth_token_or_empty())
# Get the calendar's time zone
calendar = service.calendars().get(calendarId=calendar_id).execute()
time_zone = calendar["timeZone"]
# Parse datetime strings
min_end_dt = parse_datetime(min_end_datetime, time_zone)
max_start_dt = parse_datetime(max_start_datetime, time_zone)
if min_end_dt > max_start_dt:
min_end_dt, max_start_dt = max_start_dt, min_end_dt
events_result = (
service.events()
.list(
calendarId=calendar_id,
timeMin=min_end_dt.isoformat(),
timeMax=max_start_dt.isoformat(),
maxResults=max_results,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
items_keys = [
"attachments",
"attendees",
"creator",
"description",
"end",
"eventType",
"htmlLink",
"id",
"location",
"organizer",
"start",
"summary",
"visibility",
]
events = [
{key: event[key] for key in items_keys if key in event}
for event in events_result.get("items", [])
]
return {"events_count": len(events), "events": events}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/calendar.events"],
)
)
async def update_event(
context: ToolContext,
event_id: Annotated[str, "The ID of the event to update"],
updated_start_datetime: Annotated[
str | None,
"The updated datetime that the event starts in ISO 8601 format, "
"e.g., '2024-12-31T15:30:00'.",
] = None,
updated_end_datetime: Annotated[
str | None,
"The updated datetime that the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
] = None,
updated_calendar_id: Annotated[
str | None, "The updated ID of the calendar containing the event."
] = None,
updated_summary: Annotated[str | None, "The updated title of the event"] = None,
updated_description: Annotated[str | None, "The updated description of the event"] = None,
updated_location: Annotated[str | None, "The updated location of the event"] = None,
updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None,
attendee_emails_to_add: Annotated[
list[str] | None,
"The list of attendee emails to add. Must be valid email addresses "
"e.g., username@domain.com.",
] = None,
attendee_emails_to_remove: Annotated[
list[str] | None,
"The list of attendee emails to remove. Must be valid email addresses "
"e.g., username@domain.com.",
] = None,
send_updates: Annotated[
SendUpdatesOptions,
"Should attendees be notified of the update? (none, all, external_only)",
] = SendUpdatesOptions.ALL,
) -> Annotated[
str,
"A string containing the updated event details, including the event ID, update timestamp, "
"and a link to view the updated event.",
]:
"""
Update an existing event in the specified calendar with the provided details.
Only the provided fields will be updated; others will remain unchanged.
`updated_start_datetime` and `updated_end_datetime` are
independent and can be provided separately.
"""
service = build_calendar_service(context.get_auth_token_or_empty())
calendar = service.calendars().get(calendarId="primary").execute()
time_zone = calendar["timeZone"]
try:
event = service.events().get(calendarId="primary", eventId=event_id).execute()
except HttpError:
valid_events_with_id = (
service.events()
.list(
calendarId="primary",
timeMin=(datetime.now() - timedelta(days=2)).isoformat(),
timeMax=(datetime.now() + timedelta(days=365)).isoformat(),
maxResults=50,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
raise RetryableToolError(
f"Event with ID {event_id} not found.",
additional_prompt_content=(
f"Here is a list of valid events. The event_id parameter must match one of these: "
f"{valid_events_with_id}"
),
retry_after_ms=1000,
developer_message=(
f"Event with ID {event_id} not found. Please try again with a valid event ID."
),
)
update_fields = {
"start": {"dateTime": updated_start_datetime, "timeZone": time_zone}
if updated_start_datetime
else None,
"end": {"dateTime": updated_end_datetime, "timeZone": time_zone}
if updated_end_datetime
else None,
"calendarId": updated_calendar_id,
"sendUpdates": send_updates.value if send_updates else None,
"summary": updated_summary,
"description": updated_description,
"location": updated_location,
"visibility": updated_visibility.value if updated_visibility else None,
}
event.update({k: v for k, v in update_fields.items() if v is not None})
if attendee_emails_to_remove:
event["attendees"] = [
attendee
for attendee in event.get("attendees", [])
if attendee.get("email", "").lower()
not in [email.lower() for email in attendee_emails_to_remove]
]
if attendee_emails_to_add:
existing_emails = {
attendee.get("email", "").lower() for attendee in event.get("attendees", [])
}
new_attendees = [
{"email": email}
for email in attendee_emails_to_add
if email.lower() not in existing_emails
]
event["attendees"] = event.get("attendees", []) + new_attendees
updated_event = (
service.events()
.update(
calendarId="primary",
eventId=event_id,
sendUpdates=send_updates.value,
body=event,
)
.execute()
)
return (
f"Event with ID {event_id} successfully updated at {updated_event['updated']}. "
f"View updated event at {updated_event['htmlLink']}"
)
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/calendar.events"],
)
)
async def delete_event(
context: ToolContext,
event_id: Annotated[str, "The ID of the event to delete"],
calendar_id: Annotated[str, "The ID of the calendar containing the event"] = "primary",
send_updates: Annotated[
SendUpdatesOptions, "Specifies which attendees to notify about the deletion"
] = SendUpdatesOptions.ALL,
) -> Annotated[str, "A string containing the deletion confirmation message"]:
"""Delete an event from Google Calendar."""
service = build_calendar_service(context.get_auth_token_or_empty())
service.events().delete(
calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value
).execute()
notification_message = ""
if send_updates == SendUpdatesOptions.ALL:
notification_message = "Notifications were sent to all attendees."
elif send_updates == SendUpdatesOptions.EXTERNAL_ONLY:
notification_message = "Notifications were sent to external attendees only."
elif send_updates == SendUpdatesOptions.NONE:
notification_message = "No notifications were sent to attendees."
return (
f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. "
f"{notification_message}"
)
# TODO: would be nice to have a "min_slot_duration" parameter
# TODO: find a way to have "include_weekends" parameter without confusing LLMs
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
),
)
async def find_time_slots_when_everyone_is_free(
context: ToolContext,
email_addresses: Annotated[
list[str] | None,
"The list of email addresses from people in the same organization domain (apart from the "
"currently logged in user) to search for free time slots. Defaults to None, which will "
"return free time slots for the current user only.",
] = None,
start_date: Annotated[
str | None,
"The start date to search for time slots in the format 'YYYY-MM-DD'. Defaults to today's "
"date. It will search starting from this date at the time 00:00:00.",
] = None,
end_date: Annotated[
str | None,
"The end date to search for time slots in the format 'YYYY-MM-DD'. Defaults to seven days "
"from the start date. It will search until this date at the time 23:59:59.",
] = None,
start_time_boundary: Annotated[
str,
"Will return free slots in any given day starting from this time in the format 'HH:MM'. "
"Defaults to '08:00', which is a usual business hour start time.",
] = "08:00",
end_time_boundary: Annotated[
str,
"Will return free slots in any given day until this time in the format 'HH:MM'. "
"Defaults to '18:00', which is a usual business hour end time.",
] = "18:00",
) -> Annotated[
dict,
"A dictionary with the free slots and the timezone in which time slots are represented.",
]:
"""
Provides time slots when everyone is free within a given date range and time boundaries.
"""
# Build google api services
oauth_service = build_oauth_service(context.get_auth_token_or_empty())
calendar_service = build_calendar_service(context.get_auth_token_or_empty())
email_addresses = email_addresses or []
if isinstance(email_addresses, str):
email_addresses = [email_addresses]
# Add the currently logged in user to the list of email addresses
user_info = oauth_service.userinfo().get().execute()
if user_info["email"] not in email_addresses:
email_addresses.append(user_info["email"])
# Get the timezone of the currently logged in user
calendar = calendar_service.calendars().get(calendarId="primary").execute()
timezone_name = calendar.get("timeZone")
try:
tz = ZoneInfo(timezone_name)
# If the calendar timezone name is not supported by Python's zoneinfo, use UTC
except ZoneInfoNotFoundError:
timezone_name = "UTC"
tz = ZoneInfo("UTC")
# Set default start and end dates, if not provided by the caller
start_date = start_date or datetime.now(tz=tz).date().isoformat()
end_date = end_date or (datetime.now(tz=tz).date() + timedelta(days=7)).isoformat()
# Parse start and end dates to datetime objects
start_datetime = datetime.strptime(start_date, "%Y-%m-%d").replace(
hour=0, minute=0, second=0, microsecond=0, tzinfo=tz
)
end_datetime = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, microsecond=0, tzinfo=tz
)
# Get the busy slots from the calendars of the users
freebusy_response = (
calendar_service.freebusy()
.query(
body={
"timeMin": start_datetime.isoformat(),
"timeMax": end_datetime.isoformat(),
"timeZone": timezone_name,
"items": [{"id": email_address} for email_address in email_addresses],
}
)
.execute()
)
busy_slots = freebusy_response["calendars"]
response_errors = []
for email in email_addresses:
if "errors" not in busy_slots[email]:
continue
errors = busy_slots[email]["errors"]
for error in errors:
response_errors.append(
f"Error retrieving free slots from calendar of '{email}': "
f"{error.get('reason', 'not determined')}"
)
if response_errors:
raise RetryableToolError(
"Error retrieving free slots from calendars of one or more users.",
additional_prompt_content=json.dumps(response_errors),
retry_after_ms=1000,
developer_message="Error retrieving free slots from calendars of one or more users.",
)
# Compute the free slots
free_slots = compute_free_time_intersection(
busy_data=busy_slots,
global_start=start_datetime,
global_end=end_datetime,
start_time_boundary=datetime.strptime(start_time_boundary, "%H:%M")
.time()
.replace(tzinfo=tz),
end_time_boundary=datetime.strptime(end_time_boundary, "%H:%M").time().replace(tzinfo=tz),
include_weekends=True,
tz=tz,
)
return {
"free_slots": free_slots,
"timezone": timezone_name,
}

View file

@ -0,0 +1,249 @@
import logging
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def parse_datetime(datetime_str: str, time_zone: str) -> datetime:
"""
Parse a datetime string in ISO 8601 format and ensure it is timezone-aware.
Args:
datetime_str (str): The datetime string to parse. Expected format: 'YYYY-MM-DDTHH:MM:SS'.
time_zone (str): The timezone to apply if the datetime string is naive.
Returns:
datetime: A timezone-aware datetime object.
Raises:
ValueError: If the datetime string is not in the correct format.
"""
datetime_str = datetime_str.upper().strip().rstrip("Z")
try:
dt = datetime.fromisoformat(datetime_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo(time_zone))
except ValueError as e:
raise ValueError(
f"Invalid datetime format: '{datetime_str}'. "
"Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'."
) from e
return dt
def build_oauth_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
"""
Build an OAuth2 service object.
"""
auth_token = auth_token or ""
return build("oauth2", "v2", credentials=Credentials(auth_token))
def build_calendar_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
"""
Build a Calendar service object.
"""
auth_token = auth_token or ""
return build("calendar", "v3", credentials=Credentials(auth_token))
def weekday_to_name(weekday: int) -> str:
return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][weekday]
def get_time_boundaries_for_date(
current_date: date,
global_start: datetime,
global_end: datetime,
start_time_boundary: time,
end_time_boundary: time,
tz: ZoneInfo,
) -> tuple[datetime, datetime]:
"""Compute the allowed start and end times for the given day, adjusting for global bounds."""
day_start_time = datetime.combine(current_date, start_time_boundary).replace(tzinfo=tz)
day_end_time = datetime.combine(current_date, end_time_boundary).replace(tzinfo=tz)
if current_date == global_start.date():
day_start_time = max(day_start_time, global_start)
if current_date == global_end.date():
day_end_time = min(day_end_time, global_end)
return day_start_time, day_end_time
def gather_busy_intervals(
busy_data: dict[str, Any],
day_start: datetime,
day_end: datetime,
business_tz: ZoneInfo,
) -> list[tuple[datetime, datetime]]:
"""
Collect busy intervals from all calendars that intersect with the day's business hours.
Busy intervals are clipped to lie within [day_start, day_end].
"""
busy_intervals = []
for calendar in busy_data:
for slot in busy_data[calendar].get("busy", []):
slot_start = parse_rfc3339_datetime_str(slot["start"]).astimezone(business_tz)
slot_end = parse_rfc3339_datetime_str(slot["end"]).astimezone(business_tz)
if slot_end > day_start and slot_start < day_end:
busy_intervals.append((max(slot_start, day_start), min(slot_end, day_end)))
return busy_intervals
def subtract_busy_intervals(
business_start: datetime,
business_end: datetime,
busy_intervals: list[tuple[datetime, datetime]],
) -> list[dict[str, Any]]:
"""
Subtract the merged busy intervals from the business hours and return free time slots.
"""
free_slots = []
merged_busy = merge_intervals(busy_intervals)
# If there are no busy intervals, return the entire business window as free.
if not merged_busy:
return [
{
"start": {
"datetime": business_start.isoformat(),
"weekday": weekday_to_name(business_start.weekday()),
},
"end": {
"datetime": business_end.isoformat(),
"weekday": weekday_to_name(business_end.weekday()),
},
}
]
current_free_start = business_start
for busy_start, busy_end in merged_busy:
if current_free_start < busy_start:
free_slots.append({
"start": {
"datetime": current_free_start.isoformat(),
"weekday": weekday_to_name(current_free_start.weekday()),
},
"end": {
"datetime": busy_start.isoformat(),
"weekday": weekday_to_name(busy_start.weekday()),
},
})
current_free_start = max(current_free_start, busy_end)
if current_free_start < business_end:
free_slots.append({
"start": {
"datetime": current_free_start.isoformat(),
"weekday": weekday_to_name(current_free_start.weekday()),
},
"end": {
"datetime": business_end.isoformat(),
"weekday": weekday_to_name(business_end.weekday()),
},
})
return free_slots
def compute_free_time_intersection(
busy_data: dict[str, Any],
global_start: datetime,
global_end: datetime,
start_time_boundary: time,
end_time_boundary: time,
include_weekends: bool,
tz: ZoneInfo,
) -> list[dict[str, Any]]:
"""
Returns the free time slots across all calendars within the global bounds,
ensuring that the global start is not in the past.
Only considers business days (Monday to Friday) and business hours (08:00-19:00)
in the provided timezone.
"""
# Ensure global_start is never in the past relative to now.
now = get_now(tz)
if now > global_start:
global_start = now
# If after adjusting the start, there's no interval left, return empty.
if global_start >= global_end:
return []
free_slots = []
current_date = global_start.date()
while current_date <= global_end.date():
if not include_weekends and current_date.weekday() >= 5:
current_date += timedelta(days=1)
continue
day_start, day_end = get_time_boundaries_for_date(
current_date=current_date,
global_start=global_start,
global_end=global_end,
start_time_boundary=start_time_boundary,
end_time_boundary=end_time_boundary,
tz=tz,
)
# Skip if the day's allowed time window is empty.
if day_start >= day_end:
current_date += timedelta(days=1)
continue
busy_intervals = gather_busy_intervals(busy_data, day_start, day_end, tz)
free_slots.extend(subtract_busy_intervals(day_start, day_end, busy_intervals))
current_date += timedelta(days=1)
return free_slots
def get_now(tz: ZoneInfo | None = None) -> datetime:
if not tz:
tz = ZoneInfo("UTC")
return datetime.now(tz)
def parse_rfc3339_datetime_str(dt_str: str, tz: timezone = timezone.utc) -> datetime:
"""
Parse an RFC3339 datetime string into a timezone-aware datetime.
Converts a trailing 'Z' (UTC) into +00:00.
If the parsed datetime is naive, assume it is in the provided timezone.
"""
if dt_str.endswith("Z"):
dt_str = dt_str[:-1] + "+00:00"
dt = datetime.fromisoformat(dt_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz)
return dt
def merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]:
"""
Given a list of (start, end) tuples, merge overlapping or adjacent intervals.
"""
merged: list[tuple[datetime, datetime]] = []
for start, end in sorted(intervals, key=lambda x: x[0]):
if not merged:
merged.append((start, end))
else:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged

View file

@ -0,0 +1,215 @@
from datetime import timedelta
from arcade_evals import (
BinaryCritic,
DatetimeCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_google_calendar
from arcade_google_calendar.enums import EventVisibility, SendUpdatesOptions
from arcade_google_calendar.tools import (
create_event,
delete_event,
list_calendars,
list_events,
update_event,
)
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_google_calendar)
history_after_list_events = [
{"role": "user", "content": "do i have any events on my calendar for today?"},
{
"role": "assistant",
"content": "Please go to this URL and authorize the action: \n[Link](https://accounts.google.com/o/oauth2/v2/auth?)",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_uHdRlr4z7sFm39ZrPsE5wcdT",
"type": "function",
"function": {
"name": "GoogleCalendar_ListEvents",
"arguments": '{"min_end_datetime":"2024-09-26T00:00:00-07:00","max_start_datetime":"2024-09-27T00:00:00-07:00"}',
},
}
],
},
{
"role": "tool",
"content": '{"events_count": 3, "events": [{"creator": {"email": "john@example.com", "self": true}, "description": "1:1 meeting with Joe", "end": {"dateTime": "2024-09-26T00:15:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=01234", "id": "10009199283838467", "location": "622 Rainbow Ave, South San Francisco, CA 94080, USA", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-25T23:15:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "1:1 meeting"}, {"attendees": [{"email": "joe@example.com", "responseStatus": "accepted"}], "creator": {"email": "john@example.com", "self": true}, "description": "This is just a test", "end": {"dateTime": "2024-09-26T14:00:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=OXB2OGFwcmZraWk1N234324", "id": "00099992228181818181", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-26T12:00:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "API test"}, {"attendees": [{"email": "henry@example.com", "responseStatus": "needsAction"}], "creator": {"email": "john@example.com", "self": true}, "end": {"dateTime": "2024-09-26T17:00:00-07:00", "timeZone": "America/Los_Angeles"}, "eventType": "default", "htmlLink": "https://www.google.com/calendar/event?eid=Z3I1ZzE4b324534556", "id": "gr5g18lf88tfpp3vkareukkc7g", "location": "611 Rainbow Road", "organizer": {"email": "john@example.com", "self": true}, "start": {"dateTime": "2024-09-26T15:00:00-07:00", "timeZone": "America/Los_Angeles"}, "summary": "Focus Time", "visibility": "public"}]}',
"tool_call_id": "call_uHdRlr4z7sFm39ZrPsE5wcdT",
"name": "GoogleCalendar_ListEvents",
},
{
"role": "assistant",
"content": "Yes, you have three events on your calendar for today:\n\n1. **Event:** Test2\n - **Time:** 23:15 - 00:15 (PST)\n - **Location:** 611 Gateway Blvd, South San Francisco, CA 94080, USA\n - **Description:** 1:1 meeting with Joe\n 2. **Event:** API Test\n - **Time:** 12:00 - 14:00 (PST)\n **Description:** This is just a test\n - [View Event](https://www.google.com/calendar/event?eid=OXB2OGFwcmZraWk1NGUwa24xaTNya2lvZDggZXJpY0BhcmNhZGUtYWkuY29t)\n\n3. **Event:** Focus Time\n - **Time:** 15:00 - 17:00 (PST)\n - **Location:** 611 Rainbow Road\n - **Visibility:** Public\n - [View Event](https://www.google.com/calendar/event?eid=Z3I1ZzE4bGY4OHRmcHAzdmthcmV1a2tjN2cgZXJpY0BhcmNhZGUtYWkuY29t)\n\nIf you need more details or help with anything else, feel free to ask!",
},
]
@tool_eval()
def calendar_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Calendar tools."""
suite = EvalSuite(
name="Calendar Tools Evaluation",
system_message=(
"You are an AI assistant that can create, list, update, and delete events using the provided tools. Today is 2024-09-26"
),
catalog=catalog,
rubric=rubric,
)
# Cases for list_calendars
suite.add_case(
name="List Calendars",
user_message=("What calendars do I have?"),
expected_tool_calls=[
ExpectedToolCall(
func=list_calendars,
args={},
)
],
critics=[],
)
# Cases for create_event
suite.add_case(
name="Create calendar event",
user_message=(
"Create a meeting for 'Team Meeting' starting on September 26, 2024, from 11:45pm to 12:15am. Invite johndoe@example.com"
),
expected_tool_calls=[
ExpectedToolCall(
func=create_event,
args={
"summary": "Team Meeting",
"start_datetime": "2024-09-26T23:45:00",
"end_datetime": "2024-09-27T00:15:00",
"calendar_id": "primary",
"attendee_emails": ["johndoe@example.com"],
"visibility": EventVisibility.DEFAULT,
"description": "Team Meeting",
},
)
],
critics=[
BinaryCritic(critic_field="summary", weight=0.2),
DatetimeCritic(
critic_field="start_datetime", weight=0.2, tolerance=timedelta(seconds=10)
),
DatetimeCritic(
critic_field="end_datetime", weight=0.2, tolerance=timedelta(seconds=10)
),
BinaryCritic(critic_field="attendee_emails", weight=0.2),
BinaryCritic(critic_field="description", weight=0.1),
BinaryCritic(critic_field="location", weight=0.1),
],
)
# Cases for list_events
suite.add_case(
name="List calendar events",
user_message="Do I have any events on my calendar today?",
expected_tool_calls=[
ExpectedToolCall(
func=list_events,
args={
"min_end_datetime": "2024-09-26T00:00:00",
"max_start_datetime": "2024-09-27T00:00:00",
"calendar_id": "primary",
"max_results": 10,
},
)
],
critics=[
DatetimeCritic(
critic_field="min_end_datetime", weight=0.3, tolerance=timedelta(hours=1)
),
DatetimeCritic(
critic_field="max_start_datetime", weight=0.3, tolerance=timedelta(hours=1)
),
BinaryCritic(critic_field="calendar_id", weight=0.2),
BinaryCritic(critic_field="max_results", weight=0.2),
],
)
# Cases for update_event
suite.add_case(
name="Update a calendar event",
user_message=(
"Oh no! I can't make it to the API Test since I have lunch with an old friend at that time. "
"Change my meeting tomorrow at 3pm to 4pm. Let everyone know."
),
expected_tool_calls=[
ExpectedToolCall(
func=update_event,
args={
"event_id": "00099992228181818181",
"updated_start_datetime": "2024-09-27T16:00:00",
"updated_end_datetime": "2024-09-27T18:00:00",
"updated_calendar_id": "primary",
"updated_summary": "API Test",
"updated_description": "API Test",
"updated_location": "611 Gateway Blvd",
"updated_visibility": EventVisibility.DEFAULT,
"attendee_emails_to_add": None,
"attendee_emails_to_remove": None,
"send_updates": SendUpdatesOptions.ALL,
},
)
],
critics=[
BinaryCritic(critic_field="event_id", weight=0.4),
DatetimeCritic(
critic_field="updated_start_datetime", weight=0.2, tolerance=timedelta(minutes=15)
),
DatetimeCritic(
critic_field="updated_end_datetime",
weight=0.2,
tolerance=timedelta(minutes=15),
),
BinaryCritic(critic_field="send_updates", weight=0.2),
],
additional_messages=history_after_list_events,
)
# Cases for delete_event
suite.add_case(
name="Delete a calendar event",
user_message=(
"I don't need to have focus time today. Please delete it from my calendar. Don't send any notifications."
),
expected_tool_calls=[
ExpectedToolCall(
func=delete_event,
args={
"event_id": "gr5g18lf88tfpp3vkareukkc7g",
"calendar_id": "primary",
"send_updates": SendUpdatesOptions.NONE,
},
)
],
critics=[
BinaryCritic(critic_field="event_id", weight=0.6),
BinaryCritic(critic_field="calendar_id", weight=0.2),
BinaryCritic(critic_field="send_updates", weight=0.2),
],
additional_messages=history_after_list_events,
)
return suite

View file

@ -0,0 +1,63 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_google_calendar"
version = "2.0.0"
description = "Arcade.dev LLM tools for Google Calendar"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"google-api-core>=2.19.1,<3.0.0",
"google-api-python-client>=2.137.0,<3.0.0",
"google-auth>=2.32.0,<3.0.0",
"google-auth-httplib2>=0.2.0,<1.0.0",
"googleapis-common-protos>=1.63.2,<2.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0rc1,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-mock>=3.11.1,<3.12.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = { path = "../../", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
[tool.mypy]
files = [ "arcade_google_calendar/**/*.py",]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = [ "tests",]
[tool.coverage.report]
skip_empty = true
[tool.hatch.build.targets.wheel]
packages = [ "arcade_google_calendar",]

View file

@ -0,0 +1,582 @@
from datetime import datetime
from unittest.mock import MagicMock, patch
from zoneinfo import ZoneInfo
import pytest
from arcade_tdk import ToolAuthorizationContext, ToolContext
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
from googleapiclient.errors import HttpError
from arcade_google_calendar.enums import EventVisibility, SendUpdatesOptions
from arcade_google_calendar.tools import (
create_event,
delete_event,
find_time_slots_when_everyone_is_free,
list_calendars,
list_events,
update_event,
)
@pytest.fixture
def mock_context():
mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106
return ToolContext(authorization=mock_auth)
@pytest.mark.asyncio
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_list_calendars(mock_build_calendar_service, mock_context):
mock_service = MagicMock()
mock_build_calendar_service.return_value = mock_service
expected_api_response = {
"etag": '"p33for2n0pvc8o0o"',
"items": [
{
"accessRole": "reader",
"backgroundColor": "#16a765",
"colorId": "8",
"conferenceProperties": {"allowedConferenceSolutionTypes": ["hangoutsMeet"]},
"defaultReminders": [],
"description": "Holidays and Observances in Brazil",
"etag": '"2347287866334000"',
"foregroundColor": "#000000",
"id": "en.brazilian#holiday@group.v.calendar.google.com",
"kind": "calendar#calendarListEntry",
"selected": True,
"summary": "Holidays in Brazil",
"timeZone": "America/Sao_Paulo",
},
{
"accessRole": "owner",
"backgroundColor": "#9fe1e7",
"colorId": "14",
"conferenceProperties": {"allowedConferenceSolutionTypes": ["hangoutsMeet"]},
"defaultReminders": [{"method": "popup", "minutes": 10}],
"etag": '"1743169667849567"',
"foregroundColor": "#000000",
"id": "example@arcade.dev",
"kind": "calendar#calendarListEntry",
"notificationSettings": {
"notifications": [
{"method": "email", "type": "eventCreation"},
{"method": "email", "type": "eventChange"},
{"method": "email", "type": "eventCancellation"},
{"method": "email", "type": "eventResponse"},
]
},
"primary": True,
"selected": True,
"summary": "example@arcade.dev",
"timeZone": "America/Sao_Paulo",
},
],
"kind": "calendar#calendarList",
"nextSyncToken": "XkJ8Hy5mN2pQvL9sR4tW7cA3fE1iU6nB",
}
expected_tool_response = {
"num_calendars": 2,
"calendars": [
{
"description": "Holidays and Observances in Brazil",
"id": "en.brazilian#holiday@group.v.calendar.google.com",
"summary": "Holidays in Brazil",
"timeZone": "America/Sao_Paulo",
},
{
"id": "example@arcade.dev",
"summary": "example@arcade.dev",
"timeZone": "America/Sao_Paulo",
},
],
"next_page_token": None,
}
mock_service.calendarList().list().execute.return_value = expected_api_response
response = await list_calendars(context=mock_context)
assert response == expected_tool_response
# Case: HttpError during calendars listing
mock_service.calendarList().list().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await list_calendars(context=mock_context)
@pytest.mark.asyncio
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_create_event(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Mock the calendar's time zone
mock_service.calendars().get().execute.return_value = {"timeZone": "America/Los_Angeles"}
# Case: HttpError during event creation
mock_service.events().insert().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await create_event(
context=mock_context,
summary="Test Event",
start_datetime="2024-12-31T15:30:00",
end_datetime="2024-12-31T17:30:00",
description="Test Description",
location="Test Location",
visibility=EventVisibility.PRIVATE,
attendee_emails=["test@example.com"],
)
@pytest.mark.asyncio
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_list_events(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Mock the calendar's time zone
mock_service.calendars().get().execute.return_value = {"timeZone": "America/Los_Angeles"}
# Mock the events list response
mock_events_list_response = {
"items": [
{
"creator": {"email": "example@arcade-ai.com", "self": True},
"end": {"dateTime": "2024-09-27T01:00:00-07:00", "timeZone": "America/Los_Angeles"},
"eventType": "default",
"htmlLink": "https://www.google.com/calendar/event?eid=event1",
"id": "event1",
"organizer": {"email": "example@arcade-ai.com", "self": True},
"start": {
"dateTime": "2024-09-27T00:00:00-07:00",
"timeZone": "America/Los_Angeles",
},
"summary": "Event 1",
},
{
"creator": {"email": "example@arcade-ai.com", "self": True},
"end": {"dateTime": "2024-09-27T17:00:00-07:00", "timeZone": "America/Los_Angeles"},
"eventType": "default",
"htmlLink": "https://www.google.com/calendar/event?eid=event2",
"id": "event2",
"organizer": {"email": "example@arcade-ai.com", "self": True},
"start": {
"dateTime": "2024-09-27T14:00:00-07:00",
"timeZone": "America/Los_Angeles",
},
"summary": "Event 2",
},
]
}
expected_tool_response = {
"events_count": len(mock_events_list_response["items"]),
"events": mock_events_list_response["items"],
}
mock_service.events().list().execute.return_value = mock_events_list_response
response = await list_events(
context=mock_context,
min_end_datetime="2024-09-15T09:00:00",
max_start_datetime="2024-09-16T17:00:00",
)
assert response == expected_tool_response
# Case: HttpError during events listing
mock_service.events().list().execute.side_effect = HttpError(
resp=MagicMock(status=400),
content=b'{"error": {"message": "Invalid request"}}',
)
with pytest.raises(ToolExecutionError):
await list_events(
context=mock_context,
min_end_datetime="2024-09-15T09:00:00",
max_start_datetime="2024-09-16T17:00:00",
)
@pytest.mark.asyncio
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_update_event(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
# Mock retrieval of the event
mock_service.events().get().execute.side_effect = HttpError(
resp=MagicMock(status=404),
content=b'{"error": {"message": "Event not found"}}',
)
with pytest.raises(ToolExecutionError):
await update_event(
context=mock_context,
event_id="1234567890",
updated_start_datetime="2024-12-31T00:15:00",
updated_end_datetime="2024-12-31T01:15:00",
updated_summary="Updated Event",
updated_description="Updated Description",
updated_location="Updated Location",
updated_visibility=EventVisibility.PRIVATE,
attendee_emails_to_add=["test@example.com"],
attendee_emails_to_remove=["test@example2.com"],
send_updates=SendUpdatesOptions.ALL,
)
@pytest.mark.asyncio
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_delete_event(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
mock_service.events().delete().execute.side_effect = HttpError(
resp=MagicMock(status=404),
content=b'{"error": {"message": "Event not found"}}',
)
with pytest.raises(ToolExecutionError):
await delete_event(
context=mock_context,
event_id="nonexistent_event",
send_updates=SendUpdatesOptions.ALL,
)
@pytest.mark.asyncio
@patch("arcade_google_calendar.utils.get_now")
@patch("arcade_google_calendar.tools.calendar.build_oauth_service")
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_find_free_slots_happiest_path_single_user(
mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context
):
calendar_service = MagicMock()
oauth_service = MagicMock()
mock_get_now.return_value = datetime(
2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles")
)
mock_build_oauth_service.return_value = oauth_service
mock_build_calendar_service.return_value = calendar_service
oauth_service.userinfo().get().execute.return_value = {
"email": "example@arcade.dev",
}
calendar_service.freebusy().query().execute.return_value = {
"calendars": {
"example@arcade.dev": {"busy": []},
}
}
calendar_service.calendars().get().execute.return_value = {
"timeZone": "America/Los_Angeles",
}
response = await find_time_slots_when_everyone_is_free(
context=mock_context,
email_addresses=["example@arcade.dev"],
start_date="2025-03-10",
end_date="2025-03-11",
start_time_boundary="08:00",
end_time_boundary="18:00",
)
assert response == {
"free_slots": [
{
"start": {
"datetime": "2025-03-10T09:25:00-07:00",
"weekday": "Monday",
},
"end": {
"datetime": "2025-03-10T18:00:00-07:00",
"weekday": "Monday",
},
},
{
"start": {
"datetime": "2025-03-11T08:00:00-07:00",
"weekday": "Tuesday",
},
"end": {
"datetime": "2025-03-11T18:00:00-07:00",
"weekday": "Tuesday",
},
},
],
"timezone": "America/Los_Angeles",
}
@pytest.mark.asyncio
@patch("arcade_google_calendar.utils.get_now")
@patch("arcade_google_calendar.tools.calendar.build_oauth_service")
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_find_free_slots_happiest_path_single_user_with_busy_times(
mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context
):
calendar_service = MagicMock()
oauth_service = MagicMock()
mock_get_now.return_value = datetime(
2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles")
)
mock_build_oauth_service.return_value = oauth_service
mock_build_calendar_service.return_value = calendar_service
oauth_service.userinfo().get().execute.return_value = {
"email": "example@arcade.dev",
}
calendar_service.freebusy().query().execute.return_value = {
"calendars": {
"example@arcade.dev": {
"busy": [
{
"start": "2025-03-10T11:00:00-07:00",
"end": "2025-03-10T12:00:00-07:00",
},
{
"start": "2025-03-10T14:15:00-07:00",
"end": "2025-03-10T14:30:00-07:00",
},
]
},
}
}
calendar_service.calendars().get().execute.return_value = {
"timeZone": "America/Los_Angeles",
}
response = await find_time_slots_when_everyone_is_free(
context=mock_context,
email_addresses=["example@arcade.dev"],
start_date="2025-03-10",
end_date="2025-03-11",
start_time_boundary="08:00",
end_time_boundary="18:00",
)
assert response == {
"free_slots": [
{
"start": {
"datetime": "2025-03-10T09:25:00-07:00",
"weekday": "Monday",
},
"end": {
"datetime": "2025-03-10T11:00:00-07:00",
"weekday": "Monday",
},
},
{
"start": {
"datetime": "2025-03-10T12:00:00-07:00",
"weekday": "Monday",
},
"end": {
"datetime": "2025-03-10T14:15:00-07:00",
"weekday": "Monday",
},
},
{
"start": {
"datetime": "2025-03-10T14:30:00-07:00",
"weekday": "Monday",
},
"end": {
"datetime": "2025-03-10T18:00:00-07:00",
"weekday": "Monday",
},
},
{
"start": {
"datetime": "2025-03-11T08:00:00-07:00",
"weekday": "Tuesday",
},
"end": {
"datetime": "2025-03-11T18:00:00-07:00",
"weekday": "Tuesday",
},
},
],
"timezone": "America/Los_Angeles",
}
@pytest.mark.asyncio
@patch("arcade_google_calendar.utils.get_now")
@patch("arcade_google_calendar.tools.calendar.build_oauth_service")
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_find_free_slots_happiest_path_multiple_users_with_busy_times(
mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context
):
calendar_service = MagicMock()
oauth_service = MagicMock()
mock_get_now.return_value = datetime(
2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles")
)
mock_build_oauth_service.return_value = oauth_service
mock_build_calendar_service.return_value = calendar_service
oauth_service.userinfo().get().execute.return_value = {
"email": "example@arcade.dev",
}
calendar_service.freebusy().query().execute.return_value = {
"calendars": {
"example@arcade.dev": {
"busy": [
{
"start": "2025-03-10T11:00:00-07:00",
"end": "2025-03-10T12:00:00-07:00",
},
{
"start": "2025-03-10T14:15:00-07:00",
"end": "2025-03-10T14:30:00-07:00",
},
]
},
"example2@arcade.dev": {
"busy": [
{
"start": "2025-03-10T11:30:00-07:00",
"end": "2025-03-10T12:45:00-07:00",
},
{
"start": "2025-03-11T06:00:00-07:00",
"end": "2025-03-11T07:00:00-07:00",
},
]
},
}
}
calendar_service.calendars().get().execute.return_value = {
"timeZone": "America/Los_Angeles",
}
response = await find_time_slots_when_everyone_is_free(
context=mock_context,
email_addresses=["example@arcade.dev", "example2@arcade.dev"],
start_date="2025-03-10",
end_date="2025-03-11",
start_time_boundary="08:00",
end_time_boundary="18:00",
)
assert response == {
"free_slots": [
{
"start": {
"datetime": "2025-03-10T09:25:00-07:00",
"weekday": "Monday",
},
"end": {
"datetime": "2025-03-10T11:00:00-07:00",
"weekday": "Monday",
},
},
{
"start": {
"datetime": "2025-03-10T12:45:00-07:00",
"weekday": "Monday",
},
"end": {
"datetime": "2025-03-10T14:15:00-07:00",
"weekday": "Monday",
},
},
{
"start": {
"datetime": "2025-03-10T14:30:00-07:00",
"weekday": "Monday",
},
"end": {
"datetime": "2025-03-10T18:00:00-07:00",
"weekday": "Monday",
},
},
{
"start": {
"datetime": "2025-03-11T08:00:00-07:00",
"weekday": "Tuesday",
},
"end": {
"datetime": "2025-03-11T18:00:00-07:00",
"weekday": "Tuesday",
},
},
],
"timezone": "America/Los_Angeles",
}
@pytest.mark.asyncio
@patch("arcade_google_calendar.utils.get_now")
@patch("arcade_google_calendar.tools.calendar.build_oauth_service")
@patch("arcade_google_calendar.tools.calendar.build_calendar_service")
async def test_find_free_slots_with_google_calendar_error_not_found(
mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context
):
calendar_service = MagicMock()
oauth_service = MagicMock()
mock_get_now.return_value = datetime(
2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles")
)
mock_build_oauth_service.return_value = oauth_service
mock_build_calendar_service.return_value = calendar_service
oauth_service.userinfo().get().execute.return_value = {
"email": "example@arcade.dev",
}
calendar_service.freebusy().query().execute.return_value = {
"calendars": {
"example@arcade.dev": {
"busy": [
{
"start": "2025-03-10T11:00:00-07:00",
"end": "2025-03-10T12:00:00-07:00",
},
{
"start": "2025-03-10T14:15:00-07:00",
"end": "2025-03-10T14:30:00-07:00",
},
]
},
"example2@arcade.dev": {
"errors": [
{
"reason": "notFound",
"domain": "calendar",
}
]
},
}
}
calendar_service.calendars().get().execute.return_value = {
"timeZone": "America/Los_Angeles",
}
with pytest.raises(RetryableToolError):
await find_time_slots_when_everyone_is_free(
context=mock_context,
email_addresses=["example@arcade.dev", "example2@arcade.dev"],
start_date="2025-03-10",
end_date="2025-03-11",
start_time_boundary="08:00",
end_time_boundary="18:00",
)

View file

@ -0,0 +1,18 @@
files: ^.*/google_contacts/.*
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

View file

@ -0,0 +1,46 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

View file

@ -0,0 +1,55 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -0,0 +1,7 @@
from arcade_google_contacts.tools import (
create_contact,
search_contacts_by_email,
search_contacts_by_name,
)
__all__ = ["create_contact", "search_contacts_by_email", "search_contacts_by_name"]

View file

@ -0,0 +1 @@
DEFAULT_SEARCH_CONTACTS_LIMIT = 30

View file

@ -0,0 +1,7 @@
from arcade_google_contacts.tools.contacts import (
create_contact,
search_contacts_by_email,
search_contacts_by_name,
)
__all__ = ["create_contact", "search_contacts_by_email", "search_contacts_by_name"]

View file

@ -0,0 +1,96 @@
import asyncio
from typing import Annotated
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Google
from arcade_google_contacts.constants import DEFAULT_SEARCH_CONTACTS_LIMIT
from arcade_google_contacts.utils import build_people_service, search_contacts
async def _warmup_cache(service) -> None: # type: ignore[no-untyped-def]
"""
Warm-up the search cache for contacts by sending a request with an empty query.
This ensures that the lazy cache is updated for both primary contacts and other contacts.
This is unfortunately a real thing: https://developers.google.com/people/v1/contacts#search_the_users_contacts
"""
service.people().searchContacts(query="", pageSize=1, readMask="names,emailAddresses").execute()
await asyncio.sleep(3) # TODO experiment with this value
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"]))
async def search_contacts_by_email(
context: ToolContext,
email: Annotated[str, "The email address to search for"],
limit: Annotated[
int | None,
"The maximum number of contacts to return (30 is the max allowed by Google API)",
] = DEFAULT_SEARCH_CONTACTS_LIMIT,
) -> Annotated[dict, "A dictionary containing the list of matching contacts"]:
"""
Search the user's contacts in Google Contacts by email address.
"""
service = build_people_service(context.get_auth_token_or_empty())
# Warm-up the cache before performing search.
# TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently
await _warmup_cache(service)
return {"contacts": search_contacts(service, email, limit)}
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"]))
async def search_contacts_by_name(
context: ToolContext,
name: Annotated[str, "The full name to search for"],
limit: Annotated[
int | None,
"The maximum number of contacts to return (30 is the max allowed by Google API)",
] = DEFAULT_SEARCH_CONTACTS_LIMIT,
) -> Annotated[dict, "A dictionary containing the list of matching contacts"]:
"""
Search the user's contacts in Google Contacts by name.
"""
service = build_people_service(context.get_auth_token_or_empty())
# Warm-up the cache before performing search.
# TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently
await _warmup_cache(service)
return {"contacts": search_contacts(service, name, limit)}
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts"]))
async def create_contact(
context: ToolContext,
given_name: Annotated[str, "The given name of the contact"],
family_name: Annotated[str | None, "The optional family name of the contact"],
email: Annotated[str | None, "The optional email address of the contact"],
) -> Annotated[dict, "A dictionary containing the details of the created contact"]:
"""
Create a new contact record in Google Contacts.
Examples:
```
create_contact(given_name="Alice")
create_contact(given_name="Alice", family_name="Smith")
create_contact(given_name="Alice", email="alice@example.com")
```
"""
# Build the People API service
service = build_people_service(context.get_auth_token_or_empty())
# Construct the person payload with the specified names
name_body = {"givenName": given_name}
if family_name:
name_body["familyName"] = family_name
contact_body = {"names": [name_body]}
if email:
contact_body["emailAddresses"] = [{"value": email, "type": "work"}]
# Create the contact. The personFields parameter specifies what information
# should be returned. Here, we return names and emailAddresses.
created_contact = (
service.people()
.createContact(body=contact_body, personFields="names,emailAddresses")
.execute()
)
return {"contact": created_contact}

View file

@ -0,0 +1,49 @@
import logging
from typing import Any, cast
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from arcade_google_contacts.constants import DEFAULT_SEARCH_CONTACTS_LIMIT
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def build_people_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
"""
Build a People service object.
"""
auth_token = auth_token or ""
return build("people", "v1", credentials=Credentials(auth_token))
def search_contacts(service: Any, query: str, limit: int | None) -> list[dict[str, Any]]:
"""
Search the user's contacts in Google Contacts.
"""
response = (
service.people()
.searchContacts(
query=query,
pageSize=limit or DEFAULT_SEARCH_CONTACTS_LIMIT,
readMask=",".join([
"names",
"nicknames",
"emailAddresses",
"phoneNumbers",
"addresses",
"organizations",
"biographies",
"urls",
"userDefined",
]),
)
.execute()
)
return cast(list[dict[str, Any]], response.get("results", []))

View file

@ -0,0 +1,135 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_google_contacts
from arcade_google_contacts.tools import (
create_contact,
search_contacts_by_email,
search_contacts_by_name,
)
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_google_contacts)
@tool_eval()
def contacts_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Google Contacts tools."""
suite = EvalSuite(
name="Google Contacts Tools Evaluation",
system_message="You are an AI assistant that can manage Google Contacts using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Search contacts by name",
user_message="Find my contact Bob",
expected_tool_calls=[
ExpectedToolCall(
func=search_contacts_by_name,
args={
"name": "Bob",
},
)
],
)
suite.add_case(
name="Search contacts by email",
user_message="Find my contact alice@example.com",
expected_tool_calls=[
ExpectedToolCall(
func=search_contacts_by_email,
args={
"email": "alice@example.com",
},
)
],
)
suite.add_case(
name="Search contacts with query and limit",
user_message="Find 5 contacts whose names include 'Alice'",
expected_tool_calls=[
ExpectedToolCall(
func=search_contacts_by_name,
args={
"name": "Alice",
"limit": 5,
},
)
],
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="limit", weight=0.5),
],
)
suite.add_case(
name="Create new contact with only given name",
user_message="Create a new contact for Alice",
expected_tool_calls=[
ExpectedToolCall(
func=create_contact,
args={
"given_name": "Alice",
},
)
],
critics=[
BinaryCritic(critic_field="given_name", weight=1.0),
],
)
suite.add_case(
name="Create new contact with only email (infer name from email)",
user_message="Create a new contact for alice@example.com",
expected_tool_calls=[
ExpectedToolCall(
func=create_contact,
args={
"given_name": "Alice",
"email": "alice@example.com",
},
)
],
critics=[
BinaryCritic(critic_field="email", weight=0.5),
BinaryCritic(critic_field="given_name", weight=0.5),
],
)
suite.add_case(
name="Create new contact with full name and email",
user_message="Create a contact for Bob Smith (bob.smith@example.com)",
expected_tool_calls=[
ExpectedToolCall(
func=create_contact,
args={
"given_name": "Bob",
"family_name": "Smith",
"email": "bob.smith@example.com",
},
)
],
critics=[
BinaryCritic(critic_field="given_name", weight=0.33),
BinaryCritic(critic_field="family_name", weight=0.33),
BinaryCritic(critic_field="email", weight=0.34),
],
)
return suite

View file

@ -0,0 +1,63 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_google_contacts"
version = "2.0.0"
description = "Arcade.dev LLM tools for Google Contacts"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"google-api-core>=2.19.1,<3.0.0",
"google-api-python-client>=2.137.0,<3.0.0",
"google-auth>=2.32.0,<3.0.0",
"google-auth-httplib2>=0.2.0,<1.0.0",
"googleapis-common-protos>=1.63.2,<2.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.0rc1,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-mock>=3.11.1,<3.12.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = { path = "../../", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
[tool.mypy]
files = [ "arcade_google_contacts/**/*.py",]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = [ "tests",]
[tool.coverage.report]
skip_empty = true
[tool.hatch.build.targets.wheel]
packages = [ "arcade_google_contacts",]

View file

@ -0,0 +1,100 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from arcade_tdk import ToolContext
from arcade_google_contacts.tools import create_contact
@pytest.fixture
def mock_context():
context = AsyncMock(spec=ToolContext)
context.authorization = MagicMock()
context.authorization.token = "mock_token" # noqa: S105
return context
@pytest.mark.asyncio
async def test_create_contact_success(mock_context):
# Test create_contact with all parameters (given, family names and email)
created_contact_data = {"resourceName": "people/123", "etag": "abc"}
create_contact_call = MagicMock()
create_contact_call.execute.return_value = created_contact_data
people_mock = MagicMock()
people_mock.createContact.return_value = create_contact_call
service_mock = MagicMock()
service_mock.people.return_value = people_mock
with patch(
"arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock
) as mock_build:
result = await create_contact(
mock_context,
given_name="Alice",
family_name="Smith",
email="alice@example.com",
)
assert "contact" in result
assert result["contact"] == created_contact_data
# Verify that the createContact API was called with the correct body contents.
expected_body = {
"names": [{"givenName": "Alice", "familyName": "Smith"}],
"emailAddresses": [{"value": "alice@example.com", "type": "work"}],
}
people_mock.createContact.assert_called_once_with(
body=expected_body, personFields="names,emailAddresses"
)
mock_build.assert_called_once()
@pytest.mark.asyncio
async def test_create_contact_success_without_optional(mock_context):
# Test create_contact without optional parameters family_name and email.
created_contact_data = {"resourceName": "people/456", "etag": "def"}
create_contact_call = MagicMock()
create_contact_call.execute.return_value = created_contact_data
people_mock = MagicMock()
people_mock.createContact.return_value = create_contact_call
service_mock = MagicMock()
service_mock.people.return_value = people_mock
with patch(
"arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock
):
result = await create_contact(mock_context, given_name="Bob", family_name=None, email=None)
assert "contact" in result
assert result["contact"] == created_contact_data
# Expected body should only include the givenName when family_name and email are omitted.
expected_body = {"names": [{"givenName": "Bob"}]}
people_mock.createContact.assert_called_once_with(
body=expected_body, personFields="names,emailAddresses"
)
@pytest.mark.asyncio
async def test_create_contact_error(mock_context):
# Simulate an error thrown by createContact
error_call = MagicMock()
error_call.execute.side_effect = Exception("Create error")
people_mock = MagicMock()
people_mock.createContact.return_value = error_call
service_mock = MagicMock()
service_mock.people.return_value = people_mock
with (
patch(
"arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock
),
pytest.raises(Exception, match="Error in execution of CreateContact"),
):
await create_contact(mock_context, given_name="Alice", family_name="Doe", email=None)

View file

@ -0,0 +1,18 @@
files: ^.*/google_docs/.*
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

View file

@ -0,0 +1,46 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

View file

@ -0,0 +1,55 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -0,0 +1,17 @@
from arcade_google_docs.tools import (
create_blank_document,
create_document_from_text,
get_document_by_id,
insert_text_at_end_of_document,
search_and_retrieve_documents,
search_documents,
)
__all__ = [
"create_blank_document",
"create_document_from_text",
"get_document_by_id",
"insert_text_at_end_of_document",
"search_and_retrieve_documents",
"search_documents",
]

View file

@ -0,0 +1,24 @@
import functools
from collections.abc import Callable
from typing import Any
from arcade_tdk import ToolContext
from googleapiclient.errors import HttpError
from arcade_google_docs.file_picker import generate_google_file_picker_url
def with_filepicker_fallback(func: Callable[..., Any]) -> Callable[..., Any]:
""" """
@functools.wraps(func)
async def async_wrapper(context: ToolContext, *args: Any, **kwargs: Any) -> Any:
try:
return await func(context, *args, **kwargs)
except HttpError as e:
if e.status_code in [403, 404]:
file_picker_response = generate_google_file_picker_url(context)
return file_picker_response
raise
return async_wrapper

View file

@ -0,0 +1,99 @@
def convert_document_to_html(document: dict) -> str:
html = (
"<html><head>"
f"<title>{document['title']}</title>"
f'<meta name="documentId" content="{document["documentId"]}">'
"</head><body>"
)
for element in document["body"]["content"]:
html += convert_structural_element(element)
html += "</body></html>"
return html
def convert_structural_element(element: dict, wrap_paragraphs: bool = True) -> str:
if "sectionBreak" in element or "tableOfContents" in element:
return ""
elif "paragraph" in element:
paragraph_content = ""
prepend, append = get_paragraph_style_tags(
style=element["paragraph"]["paragraphStyle"],
wrap_paragraphs=wrap_paragraphs,
)
for item in element["paragraph"]["elements"]:
if "textRun" not in item:
continue
paragraph_content += extract_paragraph_content(item["textRun"])
if not paragraph_content:
return ""
return f"{prepend}{paragraph_content.strip()}{append}"
elif "table" in element:
table = [
[
"".join([
convert_structural_element(element=cell_element, wrap_paragraphs=False)
for cell_element in cell["content"]
])
for cell in row["tableCells"]
]
for row in element["table"]["tableRows"]
]
return table_list_to_html(table)
else:
raise ValueError(f"Unknown document body element type: {element}")
def extract_paragraph_content(text_run: dict) -> str:
content = text_run["content"]
style = text_run["textStyle"]
return apply_text_style(content, style)
def apply_text_style(content: str, style: dict) -> str:
content = content.rstrip("\n")
content = content.replace("\n", "<br>")
italic = style.get("italic", False)
bold = style.get("bold", False)
if italic:
content = f"<i>{content}</i>"
if bold:
content = f"<b>{content}</b>"
return content
def get_paragraph_style_tags(style: dict, wrap_paragraphs: bool = True) -> tuple[str, str]:
named_style = style["namedStyleType"]
if named_style == "NORMAL_TEXT":
return ("<p>", "</p>") if wrap_paragraphs else ("", "")
elif named_style == "TITLE":
return "<h1>", "</h1>"
elif named_style == "SUBTITLE":
return "<h2>", "</h2>"
elif named_style.startswith("HEADING_"):
try:
heading_level = int(named_style.split("_")[1])
except ValueError:
return ("<p>", "</p>") if wrap_paragraphs else ("", "")
else:
return f"<h{heading_level}>", f"</h{heading_level}>"
return ("<p>", "</p>") if wrap_paragraphs else ("", "")
def table_list_to_html(table: list[list[str]]) -> str:
html = "<table>"
for row in table:
html += "<tr>"
for cell in row:
if cell.endswith("<br>"):
cell = cell[:-4]
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</table>"
return html

View file

@ -0,0 +1,64 @@
import arcade_google_docs.doc_to_html as doc_to_html
def convert_document_to_markdown(document: dict) -> str:
md = f"---\ntitle: {document['title']}\ndocumentId: {document['documentId']}\n---\n"
for element in document["body"]["content"]:
md += convert_structural_element(element)
return md
def convert_structural_element(element: dict) -> str:
if "sectionBreak" in element or "tableOfContents" in element:
return ""
elif "paragraph" in element:
md = ""
prepend = get_paragraph_style_prepend_str(element["paragraph"]["paragraphStyle"])
for item in element["paragraph"]["elements"]:
if "textRun" not in item:
continue
content = extract_paragraph_content(item["textRun"])
md += f"{prepend}{content}"
return md
elif "table" in element:
return doc_to_html.convert_structural_element(element)
else:
raise ValueError(f"Unknown document body element type: {element}")
def extract_paragraph_content(text_run: dict) -> str:
content = text_run["content"]
style = text_run["textStyle"]
return apply_text_style(content, style)
def apply_text_style(content: str, style: dict) -> str:
append = "\n" if content.endswith("\n") else ""
content = content.rstrip("\n")
italic = style.get("italic", False)
bold = style.get("bold", False)
if italic:
content = f"_{content}_"
if bold:
content = f"**{content}**"
return f"{content}{append}"
def get_paragraph_style_prepend_str(style: dict) -> str:
named_style = style["namedStyleType"]
if named_style == "NORMAL_TEXT":
return ""
elif named_style == "TITLE":
return "# "
elif named_style == "SUBTITLE":
return "## "
elif named_style.startswith("HEADING_"):
try:
heading_level = int(named_style.split("_")[1])
return f"{'#' * heading_level} "
except ValueError:
return ""
return ""

View file

@ -0,0 +1,116 @@
from enum import Enum
class Corpora(str, Enum):
"""
Bodies of items (files/documents) to which the query applies.
Prefer 'user' or 'drive' to 'allDrives' for efficiency.
By default, corpora is set to 'user'.
"""
USER = "user"
DOMAIN = "domain"
DRIVE = "drive"
ALL_DRIVES = "allDrives"
class DocumentFormat(str, Enum):
MARKDOWN = "markdown"
HTML = "html"
GOOGLE_API_JSON = "google_api_json"
class OrderBy(str, Enum):
"""
Sort keys for ordering files in Google Drive.
Each key has both ascending and descending options.
"""
CREATED_TIME = (
# When the file was created (ascending)
"createdTime"
)
CREATED_TIME_DESC = (
# When the file was created (descending)
"createdTime desc"
)
FOLDER = (
# The folder ID, sorted using alphabetical ordering (ascending)
"folder"
)
FOLDER_DESC = (
# The folder ID, sorted using alphabetical ordering (descending)
"folder desc"
)
MODIFIED_BY_ME_TIME = (
# The last time the file was modified by the user (ascending)
"modifiedByMeTime"
)
MODIFIED_BY_ME_TIME_DESC = (
# The last time the file was modified by the user (descending)
"modifiedByMeTime desc"
)
MODIFIED_TIME = (
# The last time the file was modified by anyone (ascending)
"modifiedTime"
)
MODIFIED_TIME_DESC = (
# The last time the file was modified by anyone (descending)
"modifiedTime desc"
)
NAME = (
# The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending)
"name"
)
NAME_DESC = (
# The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending)
"name desc"
)
NAME_NATURAL = (
# The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending)
"name_natural"
)
NAME_NATURAL_DESC = (
# The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending)
"name_natural desc"
)
QUOTA_BYTES_USED = (
# The number of storage quota bytes used by the file (ascending)
"quotaBytesUsed"
)
QUOTA_BYTES_USED_DESC = (
# The number of storage quota bytes used by the file (descending)
"quotaBytesUsed desc"
)
RECENCY = (
# The most recent timestamp from the file's date-time fields (ascending)
"recency"
)
RECENCY_DESC = (
# The most recent timestamp from the file's date-time fields (descending)
"recency desc"
)
SHARED_WITH_ME_TIME = (
# When the file was shared with the user, if applicable (ascending)
"sharedWithMeTime"
)
SHARED_WITH_ME_TIME_DESC = (
# When the file was shared with the user, if applicable (descending)
"sharedWithMeTime desc"
)
STARRED = (
# Whether the user has starred the file (ascending)
"starred"
)
STARRED_DESC = (
# Whether the user has starred the file (descending)
"starred desc"
)
VIEWED_BY_ME_TIME = (
# The last time the file was viewed by the user (ascending)
"viewedByMeTime"
)
VIEWED_BY_ME_TIME_DESC = (
# The last time the file was viewed by the user (descending)
"viewedByMeTime desc"
)

View file

@ -0,0 +1,49 @@
import base64
import json
from arcade_tdk import ToolContext, ToolMetadataKey
from arcade_tdk.errors import ToolExecutionError
def generate_google_file_picker_url(context: ToolContext) -> dict:
"""Generate a Google File Picker URL for user-driven file selection and authorization.
Generates a URL that directs the end-user to a Google File Picker interface where
where they can select or upload Google Drive files. Users can grant permission to access their
Drive files, providing a secure and authorized way to interact with their files.
This is particularly useful when prior tools (e.g., those accessing or modifying
Google Docs, Google Sheets, etc.) encountered failures due to file non-existence
(Requested entity was not found) or permission errors. Once the user completes the file
picker flow, the prior tool can be retried.
Returns:
A dictionary containing the URL and instructions for the llm to instruct the user.
"""
client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID)
client_id_parts = client_id.split("-")
if not client_id_parts:
raise ToolExecutionError(
message="Invalid Google Client ID",
developer_message=f"Google Client ID '{client_id}' is not valid",
)
app_id = client_id_parts[0]
cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/")
config = {
"auth": {
"client_id": client_id,
"app_id": app_id,
},
}
config_json = json.dumps(config)
config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8")
url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}"
return {
"url": url,
"llm_instructions": (
"Instruct the user to click the following link to open the Google Drive File Picker. "
f"This will allow them to select files and grant access permissions: {url}"
),
}

View file

@ -0,0 +1,5 @@
optional_file_picker_instructions_template = (
"Ensure the user knows that they have the option to select and grant access permissions to "
"additional documents via the Google Drive File Picker. "
"The user can pick additional documents via the following link: {url}"
)

View file

@ -0,0 +1,19 @@
from arcade_google_docs.tools.create import (
create_blank_document,
create_document_from_text,
)
from arcade_google_docs.tools.get import get_document_by_id
from arcade_google_docs.tools.search import (
search_and_retrieve_documents,
search_documents,
)
from arcade_google_docs.tools.update import insert_text_at_end_of_document
__all__ = [
"create_blank_document",
"create_document_from_text",
"get_document_by_id",
"insert_text_at_end_of_document",
"search_and_retrieve_documents",
"search_documents",
]

View file

@ -0,0 +1,82 @@
from typing import Annotated
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Google
from arcade_google_docs.utils import build_docs_service
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/create
# Example `arcade chat` query: `create blank document with title "My New Document"`
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/drive.file",
],
)
)
async def create_blank_document(
context: ToolContext, title: Annotated[str, "The title of the blank document to create"]
) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]:
"""
Create a blank Google Docs document with the specified title.
"""
service = build_docs_service(context.get_auth_token_or_empty())
body = {"title": title}
# Execute the documents().create() method. Returns a Document object https://developers.google.com/docs/api/reference/rest/v1/documents#Document
request = service.documents().create(body=body)
response = request.execute()
return {
"title": response["title"],
"documentId": response["documentId"],
"documentUrl": f"https://docs.google.com/document/d/{response['documentId']}/edit",
}
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
# Example `arcade chat` query:
# `create document with title "My New Document" and text content "Hello, World!"`
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/drive.file",
],
)
)
async def create_document_from_text(
context: ToolContext,
title: Annotated[str, "The title of the document to create"],
text_content: Annotated[str, "The text content to insert into the document"],
) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]:
"""
Create a Google Docs document with the specified title and text content.
"""
# First, create a blank document
document = await create_blank_document(context, title)
service = build_docs_service(context.get_auth_token_or_empty())
requests = [
{
"insertText": {
"location": {
"index": 1,
},
"text": text_content,
}
}
]
# Execute the batchUpdate method to insert text
service.documents().batchUpdate(
documentId=document["documentId"], body={"requests": requests}
).execute()
return {
"title": document["title"],
"documentId": document["documentId"],
"documentUrl": f"https://docs.google.com/document/d/{document['documentId']}/edit",
}

View file

@ -0,0 +1,35 @@
from typing import Annotated
from arcade_tdk import ToolContext, ToolMetadataKey, tool
from arcade_tdk.auth import Google
from arcade_google_docs.decorators import with_filepicker_fallback
from arcade_google_docs.utils import build_docs_service
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/get
# Example `arcade chat` query: `get document with ID 1234567890`
# Note: Document IDs are returned in the response of the Google Drive's `list_documents` tool
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/drive.file",
],
),
requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL],
)
@with_filepicker_fallback
async def get_document_by_id(
context: ToolContext,
document_id: Annotated[str, "The ID of the document to retrieve."],
) -> Annotated[dict, "The document contents as a dictionary"]:
"""
Get the latest version of the specified Google Docs document.
"""
service = build_docs_service(context.get_auth_token_or_empty())
# Execute the documents().get() method. Returns a Document object
# https://developers.google.com/docs/api/reference/rest/v1/documents#Document
request = service.documents().get(documentId=document_id)
response = request.execute()
return dict(response)

View file

@ -0,0 +1,219 @@
from typing import Annotated, Any
from arcade_tdk import ToolContext, ToolMetadataKey, tool
from arcade_tdk.auth import Google
from arcade_google_docs.doc_to_html import convert_document_to_html
from arcade_google_docs.doc_to_markdown import convert_document_to_markdown
from arcade_google_docs.enum import DocumentFormat, OrderBy
from arcade_google_docs.file_picker import generate_google_file_picker_url
from arcade_google_docs.templates import optional_file_picker_instructions_template
from arcade_google_docs.tools import get_document_by_id
from arcade_google_docs.utils import (
build_drive_service,
build_files_list_params,
)
# Implements: https://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.files.html#list
# Example `arcade chat` query: `list my 5 most recently modified documents`
# TODO: Support query with natural language. Currently, the tool expects a fully formed query
# string as input with the syntax defined here: https://developers.google.com/drive/api/guides/search-files
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/drive.file"],
),
requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL],
)
async def search_documents(
context: ToolContext,
document_contains: Annotated[
list[str] | None,
"Keywords or phrases that must be in the document title or body. Provide a list of "
"keywords or phrases if needed.",
] = None,
document_not_contains: Annotated[
list[str] | None,
"Keywords or phrases that must NOT be in the document title or body. Provide a list of "
"keywords or phrases if needed.",
] = None,
search_only_in_shared_drive_id: Annotated[
str | None,
"The ID of the shared drive to restrict the search to. If provided, the search will only "
"return documents from this drive. Defaults to None, which searches across all drives.",
] = None,
include_shared_drives: Annotated[
bool,
"Whether to include documents from shared drives. Defaults to False (searches only in "
"the user's 'My Drive').",
] = False,
include_organization_domain_documents: Annotated[
bool,
"Whether to include documents from the organization's domain. This is applicable to admin "
"users who have permissions to view organization-wide documents in a Google Workspace "
"account. Defaults to False.",
] = False,
order_by: Annotated[
list[OrderBy] | None,
"Sort order. Defaults to listing the most recently modified documents first",
] = None,
limit: Annotated[int, "The number of documents to list"] = 50,
pagination_token: Annotated[
str | None, "The pagination token to continue a previous request"
] = None,
) -> Annotated[
dict,
"A dictionary containing 'documents_count' (number of documents returned) and 'documents' "
"(a list of document details including 'kind', 'mimeType', 'id', and 'name' for each document)",
]:
"""
Searches for documents in the user's Google Drive. Excludes documents that are in the trash.
"""
if order_by is None:
order_by = [OrderBy.MODIFIED_TIME_DESC]
elif isinstance(order_by, OrderBy):
order_by = [order_by]
page_size = min(10, limit)
files: list[dict[str, Any]] = []
service = build_drive_service(context.get_auth_token_or_empty())
params = build_files_list_params(
mime_type="application/vnd.google-apps.document",
document_contains=document_contains,
document_not_contains=document_not_contains,
page_size=page_size,
order_by=order_by,
pagination_token=pagination_token,
include_shared_drives=include_shared_drives,
search_only_in_shared_drive_id=search_only_in_shared_drive_id,
include_organization_domain_documents=include_organization_domain_documents,
)
while len(files) < limit:
if pagination_token:
params["pageToken"] = pagination_token
else:
params.pop("pageToken", None)
results = service.files().list(**params).execute()
batch = results.get("files", [])
files.extend(batch[: limit - len(files)])
pagination_token = results.get("nextPageToken")
if not pagination_token or len(batch) < page_size:
break
file_picker_response = generate_google_file_picker_url(
context,
)
return {
"documents_count": len(files),
"documents": files,
"file_picker": {
"url": file_picker_response["url"],
"llm_instructions": optional_file_picker_instructions_template.format(
url=file_picker_response["url"]
),
},
}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/drive.file"],
),
requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL],
)
async def search_and_retrieve_documents(
context: ToolContext,
return_format: Annotated[
DocumentFormat,
"The format of the document to return. Defaults to Markdown.",
] = DocumentFormat.MARKDOWN,
document_contains: Annotated[
list[str] | None,
"Keywords or phrases that must be in the document title or body. Provide a list of "
"keywords or phrases if needed.",
] = None,
document_not_contains: Annotated[
list[str] | None,
"Keywords or phrases that must NOT be in the document title or body. Provide a list of "
"keywords or phrases if needed.",
] = None,
search_only_in_shared_drive_id: Annotated[
str | None,
"The ID of the shared drive to restrict the search to. If provided, the search will only "
"return documents from this drive. Defaults to None, which searches across all drives.",
] = None,
include_shared_drives: Annotated[
bool,
"Whether to include documents from shared drives. Defaults to False (searches only in "
"the user's 'My Drive').",
] = False,
include_organization_domain_documents: Annotated[
bool,
"Whether to include documents from the organization's domain. This is applicable to admin "
"users who have permissions to view organization-wide documents in a Google Workspace "
"account. Defaults to False.",
] = False,
order_by: Annotated[
list[OrderBy] | None,
"Sort order. Defaults to listing the most recently modified documents first",
] = None,
limit: Annotated[int, "The number of documents to list"] = 50,
pagination_token: Annotated[
str | None, "The pagination token to continue a previous request"
] = None,
) -> Annotated[
dict,
"A dictionary containing 'documents_count' (number of documents returned) and 'documents' "
"(a list of documents with their content).",
]:
"""
Searches for documents in the user's Google Drive and returns a list of documents (with text
content) matching the search criteria. Excludes documents that are in the trash.
Note: use this tool only when the user prompt requires the documents' content. If the user only
needs a list of documents, use the `search_documents` tool instead.
"""
response = await search_documents(
context=context,
document_contains=document_contains,
document_not_contains=document_not_contains,
search_only_in_shared_drive_id=search_only_in_shared_drive_id,
include_shared_drives=include_shared_drives,
include_organization_domain_documents=include_organization_domain_documents,
order_by=order_by,
limit=limit,
pagination_token=pagination_token,
)
documents = []
for item in response["documents"]:
document = await get_document_by_id(context, document_id=item["id"])
if return_format == DocumentFormat.MARKDOWN:
document = convert_document_to_markdown(document)
elif return_format == DocumentFormat.HTML:
document = convert_document_to_html(document)
documents.append(document)
file_picker_response = generate_google_file_picker_url(
context,
)
return {
"documents_count": len(documents),
"documents": documents,
"file_picker": {
"url": file_picker_response["url"],
"llm_instructions": optional_file_picker_instructions_template.format(
url=file_picker_response["url"]
),
},
}

View file

@ -0,0 +1,60 @@
from typing import Annotated
from arcade_tdk import ToolContext, ToolMetadataKey, tool
from arcade_tdk.auth import Google
from arcade_google_docs.decorators import with_filepicker_fallback
from arcade_google_docs.tools.get import get_document_by_id
from arcade_google_docs.utils import build_docs_service
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
# Example `arcade chat` query: `insert "The END" at the end of document with ID 1234567890`
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/drive.file",
],
),
requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL],
)
@with_filepicker_fallback
async def insert_text_at_end_of_document(
context: ToolContext,
document_id: Annotated[str, "The ID of the document to update."],
text_content: Annotated[str, "The text content to insert into the document"],
) -> Annotated[dict, "The response from the batchUpdate API as a dict."]:
"""
Updates an existing Google Docs document using the batchUpdate API endpoint.
"""
document_or_file_picker_response = await get_document_by_id(context, document_id)
# If the document was not found, return the file picker response
if "body" not in document_or_file_picker_response:
return document_or_file_picker_response # type: ignore[no-any-return]
document = document_or_file_picker_response
end_index = document["body"]["content"][-1]["endIndex"]
service = build_docs_service(context.get_auth_token_or_empty())
requests = [
{
"insertText": {
"location": {
"index": int(end_index) - 1,
},
"text": text_content,
}
}
]
# Execute the documents().batchUpdate() method
response = (
service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute()
)
return dict(response)

View file

@ -0,0 +1,119 @@
import logging
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from arcade_google_docs.enum import Corpora, OrderBy
## Set up basic configuration for logging to the console with DEBUG level and a specific format.
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def build_docs_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
"""
Build a Drive service object.
"""
auth_token = auth_token or ""
return build("docs", "v1", credentials=Credentials(auth_token))
def build_drive_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
"""
Build a Drive service object.
"""
auth_token = auth_token or ""
return build("drive", "v3", credentials=Credentials(auth_token))
def build_files_list_params(
mime_type: str,
page_size: int,
order_by: list[OrderBy],
pagination_token: str | None,
include_shared_drives: bool,
search_only_in_shared_drive_id: str | None,
include_organization_domain_documents: bool,
document_contains: list[str] | None = None,
document_not_contains: list[str] | None = None,
) -> dict[str, Any]:
query = build_files_list_query(
mime_type=mime_type,
document_contains=document_contains,
document_not_contains=document_not_contains,
)
params = {
"q": query,
"pageSize": page_size,
"orderBy": ",".join([item.value for item in order_by]),
"pageToken": pagination_token,
}
if (
include_shared_drives
or search_only_in_shared_drive_id
or include_organization_domain_documents
):
params["includeItemsFromAllDrives"] = "true"
params["supportsAllDrives"] = "true"
if search_only_in_shared_drive_id:
params["driveId"] = search_only_in_shared_drive_id
params["corpora"] = Corpora.DRIVE.value
if include_organization_domain_documents:
params["corpora"] = Corpora.DOMAIN.value
params = remove_none_values(params)
return params
def build_files_list_query(
mime_type: str,
document_contains: list[str] | None = None,
document_not_contains: list[str] | None = None,
) -> str:
query = [f"(mimeType = '{mime_type}' and trashed = false)"]
if isinstance(document_contains, str):
document_contains = [document_contains]
if isinstance(document_not_contains, str):
document_not_contains = [document_not_contains]
if document_contains:
for keyword in document_contains:
name_contains = keyword.replace("'", "\\'")
full_text_contains = keyword.replace("'", "\\'")
keyword_query = (
f"(name contains '{name_contains}' or fullText contains '{full_text_contains}')"
)
query.append(keyword_query)
if document_not_contains:
for keyword in document_not_contains:
name_not_contains = keyword.replace("'", "\\'")
full_text_not_contains = keyword.replace("'", "\\'")
keyword_query = (
f"(name not contains '{name_not_contains}' and "
f"fullText not contains '{full_text_not_contains}')"
)
query.append(keyword_query)
return " and ".join(query)
def remove_none_values(params: dict) -> dict:
"""
Remove None values from a dictionary.
:param params: The dictionary to clean
:return: A new dictionary with None values removed
"""
return {k: v for k, v in params.items() if v is not None}

View file

@ -0,0 +1,967 @@
import pytest
@pytest.fixture
def sample_document_and_expected_formats():
document = {
"title": "The Birth of Machine Experience Engineering",
"documentId": "1234567890",
"body": {
"content": [
{
"endIndex": 1,
"sectionBreak": {
"sectionStyle": {
"columnSeparatorStyle": "NONE",
"contentDirection": "LEFT_TO_RIGHT",
"sectionType": "CONTINUOUS",
}
},
},
{
"startIndex": 1,
"endIndex": 45,
"paragraph": {
"elements": [
{
"endIndex": 45,
"startIndex": 1,
"textRun": {
"content": "The Birth of Machine Experience Engineering\n",
"textStyle": {
"bold": True,
"fontSize": {"magnitude": 23, "unit": "PT"},
},
},
}
],
"paragraphStyle": {
"direction": "LEFT_TO_RIGHT",
"headingId": "h.wwd7ec37bh6k",
"keepLinesTogether": False,
"keepWithNext": False,
"namedStyleType": "HEADING_1",
"spaceAbove": {"magnitude": 24, "unit": "PT"},
},
},
},
{
"startIndex": 45,
"endIndex": 46,
"paragraph": {
"elements": [
{
"startIndex": 304,
"endIndex": 305,
"inlineObjectElement": {
"inlineObjectId": "kix.2s5wy5oiaf79",
"textStyle": {},
},
},
{
"endIndex": 46,
"startIndex": 45,
"textRun": {"content": "\n", "textStyle": {}},
},
],
"paragraphStyle": {
"direction": "LEFT_TO_RIGHT",
"namedStyleType": "NORMAL_TEXT",
"spaceAbove": {"magnitude": 12, "unit": "PT"},
"spaceBelow": {"magnitude": 12, "unit": "PT"},
},
},
},
{
"startIndex": 46,
"endIndex": 297,
"paragraph": {
"elements": [
{
"startIndex": 46,
"endIndex": 146,
"textRun": {
"content": (
"LLMs acting on behalf of humans and interacting with real-"
"world systems isn't theoretical anymore - "
),
"textStyle": {},
},
},
{
"startIndex": 146,
"endIndex": 175,
"textRun": {
"content": "Arcade has made it a reality.",
"textStyle": {
"bold": True,
"italic": True,
},
},
},
{
"startIndex": 175,
"endIndex": 248,
"textRun": {
"content": (
" With this shift, we're seeing the emergence of a new "
"software practice: "
),
"textStyle": {},
},
},
{
"startIndex": 248,
"endIndex": 295,
"textRun": {
"content": "Machine Experience Engineering (MX Engineering)",
"textStyle": {
"italic": True,
},
},
},
{
"startIndex": 295,
"endIndex": 297,
"textRun": {
"content": ".\n",
"textStyle": {},
},
},
],
"paragraphStyle": {
"direction": "LEFT_TO_RIGHT",
"namedStyleType": "NORMAL_TEXT",
"spaceAbove": {"magnitude": 12, "unit": "PT"},
"spaceBelow": {"magnitude": 12, "unit": "PT"},
},
},
},
{
"endIndex": 407,
"startIndex": 297,
"table": {
"columns": 3,
"rows": 3,
"tableRows": [
{
"endIndex": 338,
"startIndex": 297,
"tableCells": [
{
"content": [
{
"endIndex": 318,
"paragraph": {
"elements": [
{
"endIndex": 318,
"startIndex": 309,
"textRun": {
"content": "Column 1\n",
"textStyle": {"bold": True},
},
}
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 309,
}
],
"endIndex": 318,
"startIndex": 308,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
{
"content": [
{
"endIndex": 334,
"paragraph": {
"elements": [
{
"endIndex": 326,
"startIndex": 319,
"textRun": {
"content": "Another",
"textStyle": {"italic": True},
},
},
{
"endIndex": 334,
"startIndex": 326,
"textRun": {
"content": " column\n",
"textStyle": {},
},
},
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 319,
}
],
"endIndex": 334,
"startIndex": 318,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
{
"content": [
{
"endIndex": 348,
"paragraph": {
"elements": [
{
"endIndex": 348,
"startIndex": 335,
"textRun": {
"content": "Third column\n",
"textStyle": {},
},
}
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 335,
}
],
"endIndex": 348,
"startIndex": 334,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
],
"tableRowStyle": {"minRowHeight": {"unit": "PT"}},
},
{
"endIndex": 366,
"startIndex": 348,
"tableCells": [
{
"content": [
{
"endIndex": 356,
"paragraph": {
"elements": [
{
"endIndex": 356,
"startIndex": 350,
"textRun": {
"content": "Hello\n",
"textStyle": {},
},
}
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 350,
}
],
"endIndex": 356,
"startIndex": 349,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
{
"content": [
{
"endIndex": 364,
"paragraph": {
"elements": [
{
"endIndex": 364,
"startIndex": 357,
"textRun": {
"content": "world!\n",
"textStyle": {},
},
}
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 357,
}
],
"endIndex": 364,
"startIndex": 356,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
{
"content": [
{
"endIndex": 366,
"paragraph": {
"elements": [
{
"endIndex": 366,
"startIndex": 365,
"textRun": {
"content": "\n",
"textStyle": {},
},
}
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 365,
}
],
"endIndex": 366,
"startIndex": 364,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
],
"tableRowStyle": {"minRowHeight": {"unit": "PT"}},
},
{
"endIndex": 415,
"startIndex": 366,
"tableCells": [
{
"content": [
{
"endIndex": 388,
"paragraph": {
"elements": [
{
"endIndex": 388,
"startIndex": 368,
"textRun": {
"content": "The quick brown fox\n",
"textStyle": {},
},
}
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 368,
}
],
"endIndex": 388,
"startIndex": 367,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
{
"content": [
{
"endIndex": 401,
"paragraph": {
"elements": [
{
"endIndex": 395,
"startIndex": 389,
"textRun": {
"content": "jumped",
"textStyle": {"italic": True},
},
},
{
"endIndex": 401,
"startIndex": 395,
"textRun": {
"content": " over\n",
"textStyle": {},
},
},
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 389,
}
],
"endIndex": 401,
"startIndex": 388,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
{
"content": [
{
"endIndex": 415,
"paragraph": {
"elements": [
{
"endIndex": 415,
"startIndex": 402,
"textRun": {
"content": "the lazy dog\n",
"textStyle": {},
},
}
],
"paragraphStyle": {
"alignment": "START",
"avoidWidowAndOrphan": False,
"borderBetween": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderBottom": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderLeft": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderRight": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"borderTop": {
"color": {},
"dashStyle": "SOLID",
"padding": {"unit": "PT"},
"width": {"unit": "PT"},
},
"direction": "LEFT_TO_RIGHT",
"indentEnd": {"unit": "PT"},
"indentFirstLine": {"unit": "PT"},
"indentStart": {"unit": "PT"},
"keepLinesTogether": False,
"keepWithNext": False,
"lineSpacing": 100,
"namedStyleType": "NORMAL_TEXT",
"pageBreakBefore": False,
"shading": {"backgroundColor": {}},
"spaceAbove": {"unit": "PT"},
"spaceBelow": {"unit": "PT"},
"spacingMode": "COLLAPSE_LISTS",
},
},
"startIndex": 402,
}
],
"endIndex": 415,
"startIndex": 401,
"tableCellStyle": {
"backgroundColor": {},
"columnSpan": 1,
"contentAlignment": "TOP",
"paddingBottom": {"magnitude": 5, "unit": "PT"},
"paddingLeft": {"magnitude": 5, "unit": "PT"},
"paddingRight": {"magnitude": 5, "unit": "PT"},
"paddingTop": {"magnitude": 5, "unit": "PT"},
"rowSpan": 1,
},
},
],
"tableRowStyle": {"minRowHeight": {"unit": "PT"}},
},
],
"tableStyle": {
"tableColumnProperties": [
{"widthType": "EVENLY_DISTRIBUTED"},
{"widthType": "EVENLY_DISTRIBUTED"},
{"widthType": "EVENLY_DISTRIBUTED"},
]
},
},
},
]
},
}
expected_markdown = (
"---\ntitle: The Birth of Machine Experience Engineering\ndocumentId: 1234567890\n---\n"
"# **The Birth of Machine Experience Engineering**\n"
"\n"
"LLMs acting on behalf of humans and interacting with real-world systems isn't theoretical "
"anymore - "
"**_Arcade has made it a reality._** With this shift, we're seeing the emergence of a new "
"software practice: "
"_Machine Experience Engineering (MX Engineering)_.\n"
"<table>"
"<tr>"
"<td><b>Column 1</b></td>"
"<td><i>Another</i> column</td>"
"<td>Third column</td>"
"</tr>"
"<tr>"
"<td>Hello</td>"
"<td>world!</td>"
"<td></td>"
"</tr>"
"<tr>"
"<td>The quick brown fox</td>"
"<td><i>jumped</i> over</td>"
"<td>the lazy dog</td>"
"</tr>"
"</table>"
)
expected_html = (
"<html><head>"
"<title>The Birth of Machine Experience Engineering</title>"
'<meta name="documentId" content="1234567890">'
"</head><body>"
"<h1><b>The Birth of Machine Experience Engineering</b></h1>"
"<p>LLMs acting on behalf of humans and interacting with real-world systems isn't "
"theoretical anymore - "
"<b><i>Arcade has made it a reality.</i></b> With this shift, we're seeing the emergence "
"of a new software practice: <i>Machine Experience Engineering (MX Engineering)</i>.</p>"
"<table>"
"<tr>"
"<td><b>Column 1</b></td>"
"<td><i>Another</i> column</td>"
"<td>Third column</td>"
"</tr>"
"<tr>"
"<td>Hello</td>"
"<td>world!</td>"
"<td></td>"
"</tr>"
"<tr>"
"<td>The quick brown fox</td>"
"<td><i>jumped</i> over</td>"
"<td>the lazy dog</td>"
"</tr>"
"</table>"
"</body></html>"
)
return document, expected_markdown, expected_html

View file

@ -0,0 +1,384 @@
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
SimilarityCritic,
tool_eval,
)
from arcade_tdk import ToolCatalog
import arcade_google_docs
from arcade_google_docs.enum import DocumentFormat, OrderBy
from arcade_google_docs.tools import (
create_blank_document,
create_document_from_text,
get_document_by_id,
insert_text_at_end_of_document,
search_and_retrieve_documents,
search_documents,
)
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.9,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_google_docs)
@tool_eval()
def docs_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Google Docs tools."""
suite = EvalSuite(
name="Google Docs Tools Evaluation",
system_message="You are an AI assistant that can create and manage Google Docs using the provided tools.",
catalog=catalog,
rubric=rubric,
)
# A previous tool call to list_documents
additional_messages = [
{"role": "user", "content": "list my 10 most recently created docs"},
{
"role": "assistant",
"content": "Please go to this URL and authorize the action: [Link](https://accounts.google.com/)",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_gegK723W2hXsORjBmq1Oexqk",
"type": "function",
"function": {
"name": "Google_ListDocuments",
"arguments": '{"limit":10,"order_by":"createdTime desc"}',
},
}
],
},
{
"role": "tool",
"content": '{"documents":[{"id":"1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst10"},{"id":"1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst9"},{"id":"19Dqugn0rVi89K0C__lpg1HbhQOTenccyZOhPgivTHMs","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst8"},{"id":"1RCibzx14eqP3vS9yI4nD13OKf8Vee56RiszS53OkR7I","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst7"},{"id":"1imFb04JQuBn8SiSsRFf6fEuYCyXkbII4KX8fsmnT0jo","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst6"},{"id":"1ZC3oypdfLWFgBd-emeSykJf9tZOae6USsFboygRCr-w","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst5"},{"id":"1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst4"},{"id":"1eQ8UBO_PY3Lem4R8OVdIc9ODXt0MrSUAnEu994Qz8P8","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst3"},{"id":"1TOxB0MLry-JzntDWDT1LFywTLdr3XDWPT5L5UsHMs5c","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst2"},{"id":"1a1UQ7C90s8kGfnO8k6wfAZz_Cy5nGN2MkCoRB5y2j3w","kind":"drive#file","mimeType":"application/vnd.google-apps.document","name":"Tst1"}],"documents_count":10}',
"tool_call_id": "call_gegK723W2hXsORjBmq1Oexqk",
"name": "Google_ListDocuments",
},
{
"role": "assistant",
"content": "Here are your 10 most recently created Google Docs:\n\n1. [Tst10](https://docs.google.com/document/d/1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc)\n2. [Tst9](https://docs.google.com/document/d/1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts)\n3. [Tst8](https://docs.google.com/document/d/19Dqugn0rVi89K0C__lpg1HbhQOTenccyZOhPgivTHMs)\n4. [Tst7](https://docs.google.com/document/d/1RCibzx14eqP3vS9yI4nD13OKf8Vee56RiszS53OkR7I)\n5. [Tst6](https://docs.google.com/document/d/1imFb04JQuBn8SiSsRFf6fEuYCyXkbII4KX8fsmnT0jo)\n6. [Tst5](https://docs.google.com/document/d/1ZC3oypdfLWFgBd-emeSykJf9tZOae6USsFboygRCr-w)\n7. [Tst4](https://docs.google.com/document/d/1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc)\n8. [Tst3](https://docs.google.com/document/d/1eQ8UBO_PY3Lem4R8OVdIc9ODXt0MrSUAnEu994Qz8P8)\n9. [Tst2](https://docs.google.com/document/d/1TOxB0MLry-JzntDWDT1LFywTLdr3XDWPT5L5UsHMs5c)\n10. [Tst1](https://docs.google.com/document/d/1a1UQ7C90s8kGfnO8k6wfAZz_Cy5nGN2MkCoRB5y2j3w)\n\nYou can click the links to open each document.",
},
]
suite.add_case(
name="Get document content",
user_message="Can you read me the contents of Tst9 doc and also Tst10 doc please",
expected_tool_calls=[
ExpectedToolCall(
func=get_document_by_id,
args={
"document_id": "1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts",
},
),
ExpectedToolCall(
func=get_document_by_id,
args={
"document_id": "1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc",
},
),
],
critics=[
BinaryCritic(critic_field="document_id", weight=0.6),
],
additional_messages=additional_messages,
)
suite.add_case(
name="Insert text at end of document",
user_message="Please add the text 'This is a new paragraph.' to the end of Tst4.",
expected_tool_calls=[
ExpectedToolCall(
func=insert_text_at_end_of_document,
args={
"document_id": "1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc",
"text_content": "This is a new paragraph.",
},
)
],
critics=[
BinaryCritic(critic_field="document_id", weight=0.5),
SimilarityCritic(critic_field="text_content", weight=0.5),
],
additional_messages=additional_messages,
)
suite.add_case(
name="Read the contents of two documents and then insert text at end of a different document.",
user_message="Can you read me the contents of Tst9 doc and also Tst10 doc please. Also, please add the text 'This is a new paragraph.' to the end of Tst4.",
expected_tool_calls=[
ExpectedToolCall(
func=insert_text_at_end_of_document,
args={
"document_id": "1-gFGNWmwLxEiKa6NNixLNq3X-phXRMORVZfVTfBg8Sc",
"text_content": "This is a new paragraph.",
},
),
ExpectedToolCall(
func=get_document_by_id,
args={
"document_id": "1eTSWd-5zQds8K9OWYygwtCFMUyuuMize3bh3HaRsKts",
},
),
ExpectedToolCall(
func=get_document_by_id,
args={
"document_id": "1e0rCoT1Yd14WuuEvd3hSUcN_-VD3df4T3Q08uLm3TWc",
},
),
],
critics=[
BinaryCritic(critic_field="document_id", weight=0.3),
SimilarityCritic(critic_field="text_content", weight=0.3),
],
additional_messages=additional_messages,
)
suite.add_case(
name="Create blank document",
user_message="Create a new Doc titled 'Meeting Notes'.",
expected_tool_calls=[
ExpectedToolCall(
func=create_blank_document,
args={
"title": "Meeting Notes",
},
)
],
critics=[
SimilarityCritic(critic_field="title", weight=1.0),
],
)
suite.add_case(
name="Create document from text",
user_message="Create a new doc called To-Do List with the content 'Buy groceries, Call mom, Finish report'.",
expected_tool_calls=[
ExpectedToolCall(
func=create_document_from_text,
args={
"title": "To-Do List",
"text_content": "Buy groceries\nCall mom\nFinish report",
},
)
],
critics=[
SimilarityCritic(critic_field="title", weight=0.5),
SimilarityCritic(critic_field="text_content", weight=0.5),
],
)
suite.add_case(
name="No tool call case",
user_message="Create a new microsoft word document titled 'My Resume'.",
expected_tool_calls=[],
critics=[],
)
return suite
@tool_eval()
def search_documents_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Google Drive tools."""
suite = EvalSuite(
name="Google Drive Tools Evaluation",
system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Search documents in Google Drive",
user_message="get my 49 most recently created documents, list the ones created most recently first.",
expected_tool_calls=[
ExpectedToolCall(
func=search_documents,
args={
"order_by": [OrderBy.CREATED_TIME_DESC.value],
"limit": 49,
},
)
],
critics=[
BinaryCritic(critic_field="order_by", weight=0.5),
BinaryCritic(critic_field="limit", weight=0.5),
],
)
suite.add_case(
name="Search documents in Google Drive based on document keywords",
user_message="Search the documents that contain the word 'greedy' and the phrase 'hello, world'",
expected_tool_calls=[
ExpectedToolCall(
func=search_documents,
args={
"document_contains": ["greedy", "hello, world"],
},
)
],
critics=[
BinaryCritic(critic_field="document_contains", weight=1.0),
],
)
suite.add_case(
name="Search documents in a specific Google Drive based on document keywords",
user_message="Search the documents that contain the word 'greedy' and the phrase 'hello, world' in the drive with id 'abc123'",
expected_tool_calls=[
ExpectedToolCall(
func=search_documents,
args={
"document_contains": ["greedy", "hello, world"],
"search_only_in_shared_drive_id": "abc123",
},
)
],
critics=[
BinaryCritic(critic_field="search_only_in_shared_drive_id", weight=0.5),
BinaryCritic(critic_field="document_contains", weight=0.5),
],
)
suite.add_case(
name="Search documents in a Google Drive Workspace organization domain based on document keywords",
user_message="Search the documents that contain the phrase 'hello, world' in the organization domain",
expected_tool_calls=[
ExpectedToolCall(
func=search_documents,
args={
"document_contains": ["hello, world"],
"include_organization_domain_documents": True,
},
)
],
critics=[
BinaryCritic(critic_field="include_organization_domain_documents", weight=0.5),
BinaryCritic(critic_field="document_contains", weight=0.5),
],
)
suite.add_case(
name="Search documents in shared drives",
user_message="Search the 5 documents from all drives corpora that nobody has touched in forever, excluding shared drives.",
expected_tool_calls=[
ExpectedToolCall(
func=search_documents,
args={
"limit": 5,
"include_shared_drives": False,
},
)
],
critics=[
BinaryCritic(critic_field="include_shared_drives", weight=0.5),
BinaryCritic(critic_field="limit", weight=0.5),
],
)
suite.add_case(
name="No tool call case",
user_message="List my 10 most recently modified documents that are stored in my Microsoft OneDrive.",
expected_tool_calls=[],
critics=[],
)
return suite
@tool_eval()
def search_and_retrieve_documents_eval_suite() -> EvalSuite:
"""Create an evaluation suite for Google Drive search and retrieve tools."""
suite = EvalSuite(
name="Google Drive Tools Evaluation",
system_message="You are an AI assistant that can manage Google Drive documents using the provided tools.",
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Search and retrieve (write summary)",
user_message="Write a summary of the documents in my Google Drive about 'MX Engineering'",
expected_tool_calls=[
ExpectedToolCall(
func=search_and_retrieve_documents,
args={
"document_contains": ["MX Engineering"],
"return_format": DocumentFormat.MARKDOWN,
},
)
],
critics=[
BinaryCritic(critic_field="document_contains", weight=0.5),
BinaryCritic(critic_field="return_format", weight=0.5),
],
)
suite.add_case(
name="Search and retrieve (project proposal)",
user_message="Display the document contents in HTML format from my Google Drive that contain the phrase 'project proposal'.",
expected_tool_calls=[
ExpectedToolCall(
func=search_and_retrieve_documents,
args={
"document_contains": ["project proposal"],
"return_format": DocumentFormat.HTML,
},
)
],
critics=[
BinaryCritic(critic_field="document_contains", weight=0.5),
BinaryCritic(critic_field="return_format", weight=0.5),
],
)
suite.add_case(
name="Search and retrieve (meeting notes)",
user_message="Retrieve documents that contain both 'meeting notes' and 'budget' in JSON format.",
expected_tool_calls=[
ExpectedToolCall(
func=search_and_retrieve_documents,
args={
"document_contains": ["meeting notes", "budget"],
"return_format": DocumentFormat.GOOGLE_API_JSON,
},
)
],
critics=[
BinaryCritic(critic_field="document_contains", weight=0.5),
BinaryCritic(critic_field="return_format", weight=0.5),
],
)
suite.add_case(
name="Search and retrieve (Q1 report)",
user_message="Show me the content of the documents that mention 'Q1 report' but do not include the expression 'Project XYZ'.",
expected_tool_calls=[
ExpectedToolCall(
func=search_and_retrieve_documents,
args={
"document_contains": ["Q1 report"],
"document_not_contains": ["Project XYZ"],
"return_format": DocumentFormat.MARKDOWN,
},
)
],
critics=[
BinaryCritic(critic_field="document_contains", weight=1 / 3),
BinaryCritic(critic_field="document_not_contains", weight=1 / 3),
BinaryCritic(critic_field="return_format", weight=1 / 3),
],
)
return suite

View file

@ -0,0 +1,62 @@
[build-system]
requires = [ "hatchling",]
build-backend = "hatchling.build"
[project]
name = "arcade_google_docs"
version = "2.0.0"
description = "Arcade.dev LLM tools for Google Docs"
requires-python = ">=3.10"
dependencies = [
"arcade-tdk>=2.0.0,<3.0.0",
"google-api-core>=2.19.1,<3.0.0",
"google-api-python-client>=2.137.0,<3.0.0",
"google-auth>=2.32.0,<3.0.0",
"google-auth-httplib2>=0.2.0,<1.0.0",
"googleapis-common-protos>=1.63.2,<2.0.0",
]
[[project.authors]]
name = "Arcade"
email = "dev@arcade.dev"
[project.optional-dependencies]
dev = [
"arcade-ai[evals]>=2.0.4,<3.0.0",
"arcade-serve>=2.0.0,<3.0.0",
"pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0",
"pytest-mock>=3.11.1,<3.12.0",
"pytest-asyncio>=0.24.0,<0.25.0",
"mypy>=1.5.1,<1.6.0",
"pre-commit>=3.4.0,<3.5.0",
"tox>=4.11.1,<4.12.0",
"ruff>=0.7.4,<0.8.0",
]
# Use local path sources for arcade libs when working locally
[tool.uv.sources]
arcade-ai = { path = "../../", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
[tool.mypy]
files = [ "arcade_google_docs/**/*.py",]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = [ "tests",]
[tool.coverage.report]
skip_empty = true
[tool.hatch.build.targets.wheel]
packages = [ "arcade_google_docs",]

View file

View file

@ -0,0 +1,10 @@
import pytest
from arcade_google_docs.doc_to_markdown import convert_document_to_markdown
@pytest.mark.asyncio
async def test_convert_document_to_markdown(sample_document_and_expected_formats):
(sample_document, expected_markdown, _) = sample_document_and_expected_formats
markdown = convert_document_to_markdown(sample_document)
assert markdown == expected_markdown

View file

@ -0,0 +1,179 @@
from unittest.mock import AsyncMock, patch
import pytest
from arcade_tdk.errors import ToolExecutionError
from googleapiclient.errors import HttpError
from arcade_google_docs.tools import (
create_blank_document,
create_document_from_text,
get_document_by_id,
insert_text_at_end_of_document,
)
from arcade_google_docs.utils import build_docs_service
@pytest.fixture
def mock_context():
context = AsyncMock()
context.authorization.token = "mock_token" # noqa: S105
return context
@pytest.fixture
def mock_get_service():
with patch("arcade_google_docs.tools.get." + build_docs_service.__name__) as mock_build_service:
yield mock_build_service.return_value
@pytest.fixture
def mock_update_service():
with patch(
"arcade_google_docs.tools.update." + build_docs_service.__name__
) as mock_build_service:
yield mock_build_service.return_value
@pytest.fixture
def mock_create_service():
with patch(
"arcade_google_docs.tools.create." + build_docs_service.__name__
) as mock_build_service:
yield mock_build_service.return_value
@pytest.mark.asyncio
async def test_get_document_by_id_success(mock_context, mock_get_service):
# Mock the service.documents().get().execute() method
mock_get_service.documents.return_value.get.return_value.execute.return_value = {
"body": {"content": [{"endIndex": 1, "paragraph": {}}]},
"documentId": "test_document_id",
"title": "Test Document",
}
result = await get_document_by_id(mock_context, "test_document_id")
assert result["documentId"] == "test_document_id"
assert result["title"] == "Test Document"
@pytest.mark.asyncio
async def test_get_document_by_id_http_error(mock_context, mock_get_service):
# Simulate HttpError
mock_get_service.documents.return_value.get.return_value.execute.side_effect = HttpError(
resp=AsyncMock(status=404), content=b'{"error": {"message": "Not Found"}}'
)
with pytest.raises(ToolExecutionError, match="Error in execution of GetDocumentById"):
await get_document_by_id(mock_context, "invalid_document_id")
@pytest.mark.asyncio
async def test_insert_text_at_end_of_document_success(mock_context, mock_update_service):
# Mock get_document_by_id to return a document with endIndex
with patch(
"arcade_google_docs.tools.update.get_document_by_id",
return_value={"body": {"content": [{"endIndex": 1, "paragraph": {}}]}},
):
# Mock the service.documents().batchUpdate().execute() method
mock_update_service.documents.return_value.batchUpdate.return_value.execute.return_value = {
"documentId": "test_document_id",
"replies": [],
}
result = await insert_text_at_end_of_document(
mock_context, "test_document_id", "Sample text"
)
assert result["documentId"] == "test_document_id"
@pytest.mark.asyncio
async def test_insert_text_at_end_of_document_http_error(mock_context, mock_update_service):
with patch(
"arcade_google_docs.tools.update.get_document_by_id",
return_value={"body": {"content": [{"endIndex": 1, "paragraph": {}}]}},
):
# Simulate HttpError during batchUpdate
mock_update_service.documents.return_value.batchUpdate.return_value.execute.side_effect = (
HttpError(resp=AsyncMock(status=400), content=b'{"error": {"message": "Bad Request"}}')
)
with pytest.raises(
ToolExecutionError, match="Error in execution of InsertTextAtEndOfDocument"
):
await insert_text_at_end_of_document(mock_context, "test_document_id", "Sample text")
@pytest.mark.asyncio
async def test_create_blank_document_success(mock_context, mock_create_service):
# Mock the service.documents().create().execute() method
mock_create_service.documents.return_value.create.return_value.execute.return_value = {
"documentId": "new_document_id",
"title": "New Document",
}
result = await create_blank_document(mock_context, "New Document")
assert result["documentId"] == "new_document_id"
assert result["title"] == "New Document"
assert "documentUrl" in result
@pytest.mark.asyncio
async def test_create_blank_document_http_error(mock_context, mock_create_service):
# Simulate HttpError during create
mock_create_service.documents.return_value.create.return_value.execute.side_effect = HttpError(
resp=AsyncMock(status=403), content=b'{"error": {"message": "Forbidden"}}'
)
with pytest.raises(ToolExecutionError, match="Error in execution of CreateBlankDocument"):
await create_blank_document(mock_context, "New Document")
@pytest.mark.asyncio
async def test_create_document_from_text_success(mock_context, mock_create_service):
with patch(
"arcade_google_docs.tools.create." + create_blank_document.__name__
) as mock_create_blank_document:
# Mock create_blank_document
mock_create_blank_document.return_value = {
"documentId": "new_document_id",
"title": "New Document",
}
# Mock the service.documents().batchUpdate().execute() method
mock_create_service.documents.return_value.batchUpdate.return_value.execute.return_value = {
"documentId": "new_document_id",
"replies": [],
}
result = await create_document_from_text(mock_context, "New Document", "Hello, World!")
assert result["documentId"] == "new_document_id"
assert result["title"] == "New Document"
assert "documentUrl" in result
@pytest.mark.asyncio
async def test_create_document_from_text_http_error(mock_context, mock_create_service):
with patch(
"arcade_google_docs.tools.create." + create_blank_document.__name__
) as mock_create_blank_document:
# Mock create_blank_document
mock_create_blank_document.return_value = {
"documentId": "new_document_id",
"title": "New Document",
}
# Simulate HttpError during batchUpdate
mock_create_service.documents.return_value.batchUpdate.return_value.execute.side_effect = (
HttpError(
resp=AsyncMock(status=500), content=b'{"error": {"message": "Internal Error"}}'
)
)
with pytest.raises(
ToolExecutionError, match="Error in execution of CreateDocumentFromText"
):
await create_document_from_text(mock_context, "New Document", "Hello, World!")

View file

@ -0,0 +1,276 @@
from unittest.mock import AsyncMock, patch
import pytest
from arcade_tdk.errors import ToolExecutionError
from googleapiclient.errors import HttpError
from arcade_google_docs.enum import Corpora, DocumentFormat, OrderBy
from arcade_google_docs.templates import optional_file_picker_instructions_template
from arcade_google_docs.tools import (
search_and_retrieve_documents,
search_documents,
)
from arcade_google_docs.utils import build_drive_service
@pytest.fixture
def mock_context():
context = AsyncMock()
context.authorization.token = "mock_token" # noqa: S105
context.get_metadata.side_effect = lambda key: {
"client_id": "123456789-abcdefg.apps.googleusercontent.com",
"coordinator_url": "https://coordinator.example.com",
}.get(key.value if hasattr(key, "value") else key)
return context
@pytest.fixture
def mock_service():
with patch(
"arcade_google_docs.tools.search." + build_drive_service.__name__
) as mock_build_service:
yield mock_build_service.return_value
@pytest.mark.asyncio
async def test_search_documents_success(mock_context, mock_service):
# Mock the service.files().list().execute() method
mock_service.files.return_value.list.return_value.execute.side_effect = [
{
"files": [
{"id": "file1", "name": "Document 1"},
{"id": "file2", "name": "Document 2"},
],
"nextPageToken": None,
}
]
# Mock the generate_google_file_picker_url function
with patch(
"arcade_google_docs.tools.search.generate_google_file_picker_url"
) as mock_file_picker:
mock_file_picker.return_value = {
"url": "https://coordinator.example.com/google/drive_picker?config=test_config",
"llm_instructions": optional_file_picker_instructions_template.format(
url="https://coordinator.example.com/google/drive_picker?config=test_config"
),
}
result = await search_documents(mock_context, limit=2)
assert result["documents_count"] == 2
assert len(result["documents"]) == 2
assert result["documents"][0]["id"] == "file1"
assert result["documents"][1]["id"] == "file2"
@pytest.mark.asyncio
async def test_search_documents_pagination(mock_context, mock_service):
# Simulate multiple pages
mock_service.files.return_value.list.return_value.execute.side_effect = [
{
"files": [{"id": f"file{i}", "name": f"Document {i}"} for i in range(1, 11)],
"nextPageToken": "token1",
},
{
"files": [{"id": f"file{i}", "name": f"Document {i}"} for i in range(11, 21)],
"nextPageToken": None,
},
]
# Mock the generate_google_file_picker_url function
with patch(
"arcade_google_docs.tools.search.generate_google_file_picker_url"
) as mock_file_picker:
mock_file_picker.return_value = {
"url": "https://coordinator.example.com/google/drive_picker?config=test_config",
"llm_instructions": optional_file_picker_instructions_template.format(
url="https://coordinator.example.com/google/drive_picker?config=test_config"
),
}
result = await search_documents(mock_context, limit=15)
assert result["documents_count"] == 15
assert len(result["documents"]) == 15
assert result["documents"][0]["id"] == "file1"
assert result["documents"][-1]["id"] == "file15"
@pytest.mark.asyncio
async def test_search_documents_http_error(mock_context, mock_service):
# Simulate HttpError
mock_service.files.return_value.list.return_value.execute.side_effect = HttpError(
resp=AsyncMock(status=403), content=b'{"error": {"message": "Forbidden"}}'
)
with pytest.raises(
ToolExecutionError, match=f"Error in execution of {search_documents.__tool_name__}"
):
await search_documents(mock_context)
@pytest.mark.asyncio
async def test_search_documents_unexpected_error(mock_context, mock_service):
# Simulate unexpected exception
mock_service.files.return_value.list.return_value.execute.side_effect = Exception(
"Unexpected error"
)
with pytest.raises(
ToolExecutionError, match=f"Error in execution of {search_documents.__tool_name__}"
):
await search_documents(mock_context)
@pytest.mark.asyncio
async def test_search_documents_in_organization_domains(mock_context, mock_service):
# Mock the service.files().list().execute() method
mock_service.files.return_value.list.return_value.execute.side_effect = [
{
"files": [
{"id": "file1", "name": "Document 1"},
],
"nextPageToken": None,
}
]
# Mock the generate_google_file_picker_url function
with patch(
"arcade_google_docs.tools.search.generate_google_file_picker_url"
) as mock_file_picker:
mock_file_picker.return_value = {
"url": "https://coordinator.example.com/google/drive_picker?config=test_config",
"llm_instructions": optional_file_picker_instructions_template.format(
url="https://coordinator.example.com/google/drive_picker?config=test_config"
),
}
result = await search_documents(
mock_context,
order_by=OrderBy.MODIFIED_TIME_DESC,
include_shared_drives=False,
include_organization_domain_documents=True,
limit=1,
)
assert result["documents_count"] == 1
mock_service.files.return_value.list.assert_called_with(
q="(mimeType = 'application/vnd.google-apps.document' and trashed = false)",
corpora=Corpora.DOMAIN.value,
pageSize=1,
orderBy=OrderBy.MODIFIED_TIME_DESC.value,
includeItemsFromAllDrives="true",
supportsAllDrives="true",
)
@pytest.mark.asyncio
@patch("arcade_google_docs.tools.search.search_documents")
@patch("arcade_google_docs.tools.search.get_document_by_id")
async def test_search_and_retrieve_documents_in_markdown_format(
mock_get_document_by_id,
mock_search_documents,
mock_context,
sample_document_and_expected_formats,
):
(sample_document, expected_markdown, _) = sample_document_and_expected_formats
mock_search_documents.return_value = {
"documents_count": 1,
"documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}],
}
mock_get_document_by_id.return_value = sample_document
# Mock the generate_google_file_picker_url function
with patch(
"arcade_google_docs.tools.search.generate_google_file_picker_url"
) as mock_file_picker:
mock_file_picker.return_value = {
"url": "https://coordinator.example.com/google/drive_picker?config=test_config",
"llm_instructions": optional_file_picker_instructions_template.format(
url="https://coordinator.example.com/google/drive_picker?config=test_config"
),
}
result = await search_and_retrieve_documents(
mock_context,
document_contains=[sample_document["title"]],
return_format=DocumentFormat.MARKDOWN,
)
assert result["documents_count"] == 1
assert result["documents"][0] == expected_markdown
@pytest.mark.asyncio
@patch("arcade_google_docs.tools.search.search_documents")
@patch("arcade_google_docs.tools.search.get_document_by_id")
async def test_search_and_retrieve_documents_in_html_format(
mock_get_document_by_id,
mock_search_documents,
mock_context,
sample_document_and_expected_formats,
):
(sample_document, _, expected_html) = sample_document_and_expected_formats
mock_search_documents.return_value = {
"documents_count": 1,
"documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}],
}
mock_get_document_by_id.return_value = sample_document
# Mock the generate_google_file_picker_url function
with patch(
"arcade_google_docs.tools.search.generate_google_file_picker_url"
) as mock_file_picker:
mock_file_picker.return_value = {
"url": "https://coordinator.example.com/google/drive_picker?config=test_config",
"llm_instructions": optional_file_picker_instructions_template.format(
url="https://coordinator.example.com/google/drive_picker?config=test_config"
),
}
result = await search_and_retrieve_documents(
mock_context,
document_contains=[sample_document["title"]],
return_format=DocumentFormat.HTML,
)
assert result["documents_count"] == 1
assert result["documents"][0] == expected_html
@pytest.mark.asyncio
@patch("arcade_google_docs.tools.search.search_documents")
@patch("arcade_google_docs.tools.search.get_document_by_id")
async def test_search_and_retrieve_documents_in_google_json_format(
mock_get_document_by_id,
mock_search_documents,
mock_context,
sample_document_and_expected_formats,
):
(sample_document, _, _) = sample_document_and_expected_formats
mock_search_documents.return_value = {
"documents_count": 1,
"documents": [{"id": sample_document["documentId"], "title": sample_document["title"]}],
}
mock_get_document_by_id.return_value = sample_document
# Mock the generate_google_file_picker_url function
with patch(
"arcade_google_docs.tools.search.generate_google_file_picker_url"
) as mock_file_picker:
mock_file_picker.return_value = {
"url": "https://coordinator.example.com/google/drive_picker?config=test_config",
"llm_instructions": optional_file_picker_instructions_template.format(
url="https://coordinator.example.com/google/drive_picker?config=test_config"
),
}
result = await search_and_retrieve_documents(
mock_context,
document_contains=[sample_document["title"]],
return_format=DocumentFormat.GOOGLE_API_JSON,
)
assert result["documents_count"] == 1
assert result["documents"][0] == sample_document

View file

@ -0,0 +1,18 @@
files: ^.*/google_drive/.*
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

View file

@ -0,0 +1,46 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

View file

@ -0,0 +1,55 @@
.PHONY: help
help:
@echo "🛠️ github Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the uv environment and install all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras --no-sources
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: install-local
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
@echo "🚀 Creating virtual environment and installing all packages using uv"
@uv sync --active --all-extras
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
@echo "✅ All packages and dependencies installed via uv"
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
uv build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
@uv run --no-sources coverage report
@echo "Generating coverage report"
@uv run --no-sources coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file by a patch version
@echo "🚀 Bumping version in pyproject.toml"
uv version --no-sources --bump patch
.PHONY: check
check: ## Run code quality tools.
@if [ -f .pre-commit-config.yaml ]; then\
echo "🚀 Linting code: Running pre-commit";\
uv run --no-sources pre-commit run -a;\
fi
@echo "🚀 Static type checking: Running mypy"
@uv run --no-sources mypy --config-file=pyproject.toml

View file

@ -0,0 +1,3 @@
from arcade_google_drive.tools import generate_google_file_picker_url, get_file_tree_structure
__all__ = ["generate_google_file_picker_url", "get_file_tree_structure"]

View file

@ -0,0 +1,116 @@
from enum import Enum
class Corpora(str, Enum):
"""
Bodies of items (files/documents) to which the query applies.
Prefer 'user' or 'drive' to 'allDrives' for efficiency.
By default, corpora is set to 'user'.
"""
USER = "user"
DOMAIN = "domain"
DRIVE = "drive"
ALL_DRIVES = "allDrives"
class OrderBy(str, Enum):
"""
Sort keys for ordering files in Google Drive.
Each key has both ascending and descending options.
"""
CREATED_TIME = (
# When the file was created (ascending)
"createdTime"
)
CREATED_TIME_DESC = (
# When the file was created (descending)
"createdTime desc"
)
FOLDER = (
# The folder ID, sorted using alphabetical ordering (ascending)
"folder"
)
FOLDER_DESC = (
# The folder ID, sorted using alphabetical ordering (descending)
"folder desc"
)
MODIFIED_BY_ME_TIME = (
# The last time the file was modified by the user (ascending)
"modifiedByMeTime"
)
MODIFIED_BY_ME_TIME_DESC = (
# The last time the file was modified by the user (descending)
"modifiedByMeTime desc"
)
MODIFIED_TIME = (
# The last time the file was modified by anyone (ascending)
"modifiedTime"
)
MODIFIED_TIME_DESC = (
# The last time the file was modified by anyone (descending)
"modifiedTime desc"
)
NAME = (
# The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (ascending)
"name"
)
NAME_DESC = (
# The name of the file, sorted using alphabetical ordering (e.g., 1, 12, 2, 22) (descending)
"name desc"
)
NAME_NATURAL = (
# The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (ascending)
"name_natural"
)
NAME_NATURAL_DESC = (
# The name of the file, sorted using natural sort ordering (e.g., 1, 2, 12, 22) (descending)
"name_natural desc"
)
QUOTA_BYTES_USED = (
# The number of storage quota bytes used by the file (ascending)
"quotaBytesUsed"
)
QUOTA_BYTES_USED_DESC = (
# The number of storage quota bytes used by the file (descending)
"quotaBytesUsed desc"
)
RECENCY = (
# The most recent timestamp from the file's date-time fields (ascending)
"recency"
)
RECENCY_DESC = (
# The most recent timestamp from the file's date-time fields (descending)
"recency desc"
)
SHARED_WITH_ME_TIME = (
# When the file was shared with the user, if applicable (ascending)
"sharedWithMeTime"
)
SHARED_WITH_ME_TIME_DESC = (
# When the file was shared with the user, if applicable (descending)
"sharedWithMeTime desc"
)
STARRED = (
# Whether the user has starred the file (ascending)
"starred"
)
STARRED_DESC = (
# Whether the user has starred the file (descending)
"starred desc"
)
VIEWED_BY_ME_TIME = (
# The last time the file was viewed by the user (ascending)
"viewedByMeTime"
)
VIEWED_BY_ME_TIME_DESC = (
# The last time the file was viewed by the user (descending)
"viewedByMeTime desc"
)
class DocumentFormat(str, Enum):
MARKDOWN = "markdown"
HTML = "html"
GOOGLE_API_JSON = "google_api_json"

View file

@ -0,0 +1,5 @@
optional_file_picker_instructions_template = (
"Ensure the user knows that they have the option to select and grant access permissions to "
"additional files and folders via the Google Drive File Picker. "
"The user can pick additional files and folders via the following link: {url}"
)

View file

@ -0,0 +1,6 @@
from arcade_google_drive.tools.drive import generate_google_file_picker_url, get_file_tree_structure
__all__ = [
"generate_google_file_picker_url",
"get_file_tree_structure",
]

View file

@ -0,0 +1,167 @@
import base64
import json
from typing import Annotated
from arcade_tdk import ToolContext, ToolMetadataKey, tool
from arcade_tdk.auth import Google
from arcade_tdk.errors import ToolExecutionError
from googleapiclient.errors import HttpError
from arcade_google_drive.enums import OrderBy
from arcade_google_drive.templates import optional_file_picker_instructions_template
from arcade_google_drive.utils import (
build_drive_service,
build_file_tree,
build_file_tree_request_params,
)
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/drive.file"],
),
requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL],
)
async def get_file_tree_structure(
context: ToolContext,
include_shared_drives: Annotated[
bool, "Whether to include shared drives in the file tree structure. Defaults to False."
] = False,
restrict_to_shared_drive_id: Annotated[
str | None,
"If provided, only include files from this shared drive in the file tree structure. "
"Defaults to None, which will include files and folders from all drives.",
] = None,
include_organization_domain_documents: Annotated[
bool,
"Whether to include documents from the organization's domain. This is applicable to admin "
"users who have permissions to view organization-wide documents in a Google Workspace "
"account. Defaults to False.",
] = False,
order_by: Annotated[
list[OrderBy] | None,
"Sort order. Defaults to listing the most recently modified documents first",
] = None,
limit: Annotated[
int | None,
"The number of files and folders to list. Defaults to None, "
"which will list all files and folders.",
] = None,
) -> Annotated[
dict,
"A dictionary containing the file/folder tree structure in the user's Google Drive",
]:
"""
Get the file/folder tree structure of the user's Google Drive.
"""
service = build_drive_service(context.get_auth_token_or_empty())
keep_paginating = True
page_token = None
files = {}
file_tree: dict[str, list[dict]] = {"My Drive": []}
params = build_file_tree_request_params(
order_by,
page_token,
limit,
include_shared_drives,
restrict_to_shared_drive_id,
include_organization_domain_documents,
)
while keep_paginating:
# Get a list of files
results = service.files().list(**params).execute()
# Update page token
page_token = results.get("nextPageToken")
params["pageToken"] = page_token
keep_paginating = page_token is not None
for file in results.get("files", []):
files[file["id"]] = file
if not files:
return {"drives": []}
file_tree = build_file_tree(files)
drives = []
for drive_id, files in file_tree.items(): # type: ignore[assignment]
if drive_id == "My Drive":
drive = {"name": "My Drive", "children": files}
else:
try:
drive_details = service.drives().get(driveId=drive_id).execute()
drive_name = drive_details.get("name", "Shared Drive (name unavailable)")
except HttpError as e:
drive_name = (
f"Shared Drive (name unavailable: 'HttpError {e.status_code}: {e.reason}')"
)
drive = {"name": drive_name, "id": drive_id, "children": files}
drives.append(drive)
file_picker_response = generate_google_file_picker_url(
context,
)
return {
"drives": drives,
"file_picker": {
"url": file_picker_response["url"],
"llm_instructions": optional_file_picker_instructions_template.format(
url=file_picker_response["url"]
),
},
}
@tool(
requires_auth=Google(),
requires_metadata=[ToolMetadataKey.CLIENT_ID, ToolMetadataKey.COORDINATOR_URL],
)
def generate_google_file_picker_url(
context: ToolContext,
) -> Annotated[dict, "Google File Picker URL for user file selection and permission granting"]:
"""Generate a Google File Picker URL for user-driven file selection and authorization.
This tool generates a URL that directs the end-user to a Google File Picker interface where
where they can select or upload Google Drive files. Users can grant permission to access their
Drive files, providing a secure and authorized way to interact with their files.
This is particularly useful when prior tools (e.g., those accessing or modifying
Google Docs, Google Sheets, etc.) encountered failures due to file non-existence
(Requested entity was not found) or permission errors. Once the user completes the file
picker flow, the prior tool can be retried.
"""
client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID)
client_id_parts = client_id.split("-")
if not client_id_parts:
raise ToolExecutionError(
message="Invalid Google Client ID",
developer_message=f"Google Client ID '{client_id}' is not valid",
)
app_id = client_id_parts[0]
cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/")
config = {
"auth": {
"client_id": client_id,
"app_id": app_id,
},
}
config_json = json.dumps(config)
config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8")
url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}"
return {
"url": url,
"llm_instructions": (
"Instruct the user to click the following link to open the Google Drive File Picker. "
"This will allow them to select files and grant access permissions: {url}"
),
}

View file

@ -0,0 +1,114 @@
import logging
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from arcade_google_drive.enums import Corpora, OrderBy
## Set up basic configuration for logging to the console with DEBUG level and a specific format.
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def build_drive_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
"""
Build a Drive service object.
"""
auth_token = auth_token or ""
return build("drive", "v3", credentials=Credentials(auth_token))
def build_file_tree_request_params(
order_by: list[OrderBy] | None,
page_token: str | None,
limit: int | None,
include_shared_drives: bool,
restrict_to_shared_drive_id: str | None,
include_organization_domain_documents: bool,
) -> dict[str, Any]:
if order_by is None:
order_by = [OrderBy.MODIFIED_TIME_DESC]
elif isinstance(order_by, OrderBy):
order_by = [order_by]
params = {
"q": "trashed = false",
"corpora": Corpora.USER.value,
"pageToken": page_token,
"fields": (
"files(id, name, parents, mimeType, driveId, size, createdTime, modifiedTime, owners)"
),
"orderBy": ",".join([item.value for item in order_by]),
}
if limit:
params["pageSize"] = str(limit)
if (
include_shared_drives
or restrict_to_shared_drive_id
or include_organization_domain_documents
):
params["includeItemsFromAllDrives"] = "true"
params["supportsAllDrives"] = "true"
if restrict_to_shared_drive_id:
params["driveId"] = restrict_to_shared_drive_id
params["corpora"] = Corpora.DRIVE.value
if include_organization_domain_documents:
params["corpora"] = Corpora.DOMAIN.value
return params
def build_file_tree(files: dict[str, Any]) -> dict[str, Any]:
file_tree: dict[str, Any] = {}
for file in files.values():
owners = file.get("owners", [])
if owners:
owners = [
{"name": owner.get("displayName", ""), "email": owner.get("emailAddress", "")}
for owner in owners
]
file["owners"] = owners
if "size" in file:
file["size"] = {"value": int(file["size"]), "unit": "bytes"}
# Although "parents" is a list, a file can only have one parent
try:
parent_id = file["parents"][0]
del file["parents"]
except (KeyError, IndexError):
parent_id = None
# Determine the file's Drive ID
if "driveId" in file:
drive_id = file["driveId"]
del file["driveId"]
# If a shared drive id is not present, the file is in "My Drive"
else:
drive_id = "My Drive"
if drive_id not in file_tree:
file_tree[drive_id] = []
# Root files will have the Drive's id as the parent. If the parent id is not in the files
# list, the file must be at drive's root
if parent_id not in files:
file_tree[drive_id].append(file)
# Associate the file with its parent
else:
if "children" not in files[parent_id]:
files[parent_id]["children"] = []
files[parent_id]["children"].append(file)
return file_tree

View file

@ -0,0 +1,197 @@
import pytest
@pytest.fixture
def sample_drive_file_tree_request_responses() -> tuple[dict, list]:
files_list = {
"files": [
# Shared Drive 1 files and folders
{
"id": "19WVyQndQsc0AxxfdrIt5CvDQd6r-BvpqnB8bWZoL7Xk",
"name": "shared-1-folder-1-doc-1",
"mimeType": "application/vnd.google-apps.document",
"parents": ["1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt"],
"createdTime": "2025-02-26T00:28:20.571Z",
"modifiedTime": "2025-02-26T00:28:30.773Z",
"driveId": "0AFqcR6obkydtUk9PVA",
"size": "1024",
},
{
"id": "1dCOCdPxhTqiB3j3bWrIWM692ZbL8dyjt",
"name": "shared-1-folder-1",
"mimeType": "application/vnd.google-apps.folder",
"parents": ["0AFqcR6obkydtUk9PVA"],
"createdTime": "2025-02-26T00:27:45.526Z",
"modifiedTime": "2025-02-26T00:27:45.526Z",
"driveId": "0AFqcR6obkydtUk9PVA",
},
{
"id": "1didt_h-tDjuJ-dmYtHUSyOCPci30K_kSszvg0G3tKBM",
"name": "shared-1-doc-1",
"mimeType": "application/vnd.google-apps.document",
"parents": ["0AFqcR6obkydtUk9PVA"],
"createdTime": "2025-02-26T00:27:19.287Z",
"modifiedTime": "2025-02-26T00:27:26.079Z",
"driveId": "0AFqcR6obkydtUk9PVA",
"size": "1024",
},
# My Drive files and folders
{
"id": "1vB6sv0MD0hYSraYvWU_fcci3GN_-Jf4g-LfyXdG8ZMo",
"name": "The Birth of MX Engineering",
"mimeType": "application/vnd.google-apps.document",
"parents": ["0AIbBwO2hjeHqUk9PVA"],
"createdTime": "2025-01-24T06:34:22.305Z",
"modifiedTime": "2025-02-25T21:54:30.632Z",
"owners": [
{
"kind": "drive#user",
"displayName": "one_new_tool_everyday",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": True,
"permissionId": "00356981722324419750",
"emailAddress": "one_new_tool_everyday@arcade.dev",
}
],
"size": "6634",
},
{
"id": "1wv2dmYo0skJTI59ZIcwH9vm-wt7psMwXTvihuEGeHeI",
"name": "test document 1.1.1",
"mimeType": "application/vnd.google-apps.document",
"parents": ["1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO"],
"createdTime": "2025-02-25T17:59:03.325Z",
"modifiedTime": "2025-02-25T17:59:11.445Z",
"owners": [
{
"kind": "drive#user",
"displayName": "one_new_tool_everyday",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": True,
"permissionId": "00356981722324419750",
"emailAddress": "one_new_tool_everyday@arcade.dev",
}
],
"size": "1024",
},
{
"id": "1J92V9yvVWm_uNHq3CCY4wyG1H9B6iiwO",
"name": "test folder 1.1",
"mimeType": "application/vnd.google-apps.folder",
"parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"],
"createdTime": "2025-02-25T17:58:58.987Z",
"modifiedTime": "2025-02-25T17:58:58.987Z",
"owners": [
{
"kind": "drive#user",
"displayName": "one_new_tool_everyday",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": True,
"permissionId": "00356981722324419750",
"emailAddress": "one_new_tool_everyday@arcade.dev",
}
],
},
{
"id": "1DSmL7d07kjT6b6L-t4JIT06ElUbZ1q0K6_gEpn_UGZ8",
"name": "test document 1.2",
"mimeType": "application/vnd.google-apps.document",
"parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"],
"createdTime": "2025-02-25T17:58:38.628Z",
"modifiedTime": "2025-02-25T17:58:46.713Z",
"owners": [
{
"kind": "drive#user",
"displayName": "one_new_tool_everyday",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": True,
"permissionId": "00356981722324419750",
"emailAddress": "one_new_tool_everyday@arcade.dev",
}
],
"size": "1024",
},
{
"id": "1Fcxz7HsyO2Zyc-5DTD3zBQnaVrZwD29BP9KD9rPnYfE",
"name": "test document 1.1",
"mimeType": "application/vnd.google-apps.document",
"parents": ["1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo"],
"createdTime": "2025-02-25T17:57:53.850Z",
"modifiedTime": "2025-02-25T17:58:28.745Z",
"owners": [
{
"kind": "drive#user",
"displayName": "one_new_tool_everyday",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": True,
"permissionId": "00356981722324419750",
"emailAddress": "one_new_tool_everyday@arcade.dev",
}
],
"size": "1024",
},
{
"id": "1gqioaHG53jPVeJN5gBpHoO-GWtwiJcLo",
"name": "test folder 1",
"mimeType": "application/vnd.google-apps.folder",
"parents": ["0AIbBwO2hjeHqUk9PVA"],
"createdTime": "2025-02-25T17:57:46.036Z",
"modifiedTime": "2025-02-25T17:57:46.036Z",
"owners": [
{
"kind": "drive#user",
"displayName": "one_new_tool_everyday",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": True,
"permissionId": "00356981722324419750",
"emailAddress": "one_new_tool_everyday@arcade.dev",
}
],
},
{
"id": "16PUe97yGQeOjQgrgd54iCoxzid4SEvu_J33P_ELd5r8",
"name": "Hello world presentation",
"mimeType": "application/vnd.google-apps.presentation",
"createdTime": "2025-02-18T20:48:52.786Z",
"modifiedTime": "2025-02-19T23:31:20.483Z",
"owners": [
{
"kind": "drive#user",
"displayName": "john.doe",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": False,
"permissionId": "06420661154928749996",
"emailAddress": "john.doe@arcade.dev",
}
],
"size": "15774558",
},
{
"id": "1nG7lSvIyK05N9METPczVJa4iGgE7uoo-A6zpqjpUsDY",
"name": "Shared doc 1",
"mimeType": "application/vnd.google-apps.document",
"createdTime": "2025-02-19T18:51:44.622Z",
"modifiedTime": "2025-02-19T19:30:39.773Z",
"owners": [
{
"kind": "drive#user",
"displayName": "theboss",
"photoLink": "https://lh3.googleusercontent.com/a-/photo.png",
"me": False,
"permissionId": "11571864250637401873",
"emailAddress": "theboss@arcade.dev",
}
],
"size": "2700",
},
],
}
drives_get = [
{
"id": "0AFqcR6obkydtUk9PVA",
"name": "Shared Drive 1",
}
]
return files_list, drives_get

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