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:
parent
a30fc9379a
commit
07c52100f3
290 changed files with 22664 additions and 1 deletions
11
.github/workflows/test-toolkits.yml
vendored
11
.github/workflows/test-toolkits.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
toolkits/e2b/.pre-commit-config.yaml
Normal file
18
toolkits/e2b/.pre-commit-config.yaml
Normal 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
47
toolkits/e2b/.ruff.toml
Normal 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
21
toolkits/e2b/LICENSE
Normal 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
55
toolkits/e2b/Makefile
Normal 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
|
||||
3
toolkits/e2b/arcade_e2b/__init__.py
Normal file
3
toolkits/e2b/arcade_e2b/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from arcade_e2b.tools import create_static_matplotlib_chart, run_code
|
||||
|
||||
__all__ = ["create_static_matplotlib_chart", "run_code"]
|
||||
10
toolkits/e2b/arcade_e2b/enums.py
Normal file
10
toolkits/e2b/arcade_e2b/enums.py
Normal 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"
|
||||
4
toolkits/e2b/arcade_e2b/tools/__init__.py
Normal file
4
toolkits/e2b/arcade_e2b/tools/__init__.py
Normal 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"]
|
||||
31
toolkits/e2b/arcade_e2b/tools/create_chart.py
Normal file
31
toolkits/e2b/arcade_e2b/tools/create_chart.py
Normal 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
|
||||
27
toolkits/e2b/arcade_e2b/tools/run_code.py
Normal file
27
toolkits/e2b/arcade_e2b/tools/run_code.py
Normal 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())
|
||||
120
toolkits/e2b/evals/eval_e2b.py
Normal file
120
toolkits/e2b/evals/eval_e2b.py
Normal 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
|
||||
57
toolkits/e2b/pyproject.toml
Normal file
57
toolkits/e2b/pyproject.toml
Normal 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",]
|
||||
0
toolkits/e2b/tests/__init__.py
Normal file
0
toolkits/e2b/tests/__init__.py
Normal file
74
toolkits/e2b/tests/test_e2b.py
Normal file
74
toolkits/e2b/tests/test_e2b.py
Normal 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"}',
|
||||
}
|
||||
18
toolkits/firecrawl/.pre-commit-config.yaml
Normal file
18
toolkits/firecrawl/.pre-commit-config.yaml
Normal 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
|
||||
47
toolkits/firecrawl/.ruff.toml
Normal file
47
toolkits/firecrawl/.ruff.toml
Normal 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/firecrawl/LICENSE
Normal file
21
toolkits/firecrawl/LICENSE
Normal 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/firecrawl/Makefile
Normal file
55
toolkits/firecrawl/Makefile
Normal 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
|
||||
17
toolkits/firecrawl/arcade_firecrawl/__init__.py
Normal file
17
toolkits/firecrawl/arcade_firecrawl/__init__.py
Normal 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",
|
||||
]
|
||||
11
toolkits/firecrawl/arcade_firecrawl/enums.py
Normal file
11
toolkits/firecrawl/arcade_firecrawl/enums.py
Normal 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"
|
||||
17
toolkits/firecrawl/arcade_firecrawl/tools/__init__.py
Normal file
17
toolkits/firecrawl/arcade_firecrawl/tools/__init__.py
Normal 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",
|
||||
]
|
||||
121
toolkits/firecrawl/arcade_firecrawl/tools/crawl.py
Normal file
121
toolkits/firecrawl/arcade_firecrawl/tools/crawl.py
Normal 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)
|
||||
33
toolkits/firecrawl/arcade_firecrawl/tools/map.py
Normal file
33
toolkits/firecrawl/arcade_firecrawl/tools/map.py
Normal 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)
|
||||
49
toolkits/firecrawl/arcade_firecrawl/tools/scrape.py
Normal file
49
toolkits/firecrawl/arcade_firecrawl/tools/scrape.py
Normal 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)
|
||||
244
toolkits/firecrawl/evals/eval_firecrawl.py
Normal file
244
toolkits/firecrawl/evals/eval_firecrawl.py
Normal 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
|
||||
54
toolkits/firecrawl/pyproject.toml
Normal file
54
toolkits/firecrawl/pyproject.toml
Normal 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",]
|
||||
0
toolkits/firecrawl/tests/__init__.py
Normal file
0
toolkits/firecrawl/tests/__init__.py
Normal file
129
toolkits/firecrawl/tests/test_firecrawl.py
Normal file
129
toolkits/firecrawl/tests/test_firecrawl.py
Normal 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)
|
||||
18
toolkits/gmail/.pre-commit-config.yaml
Normal file
18
toolkits/gmail/.pre-commit-config.yaml
Normal 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
46
toolkits/gmail/.ruff.toml
Normal 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
55
toolkits/gmail/Makefile
Normal 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
|
||||
0
toolkits/gmail/arcade_gmail/__init__.py
Normal file
0
toolkits/gmail/arcade_gmail/__init__.py
Normal file
18
toolkits/gmail/arcade_gmail/constants.py
Normal file
18
toolkits/gmail/arcade_gmail/constants.py
Normal 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
|
||||
11
toolkits/gmail/arcade_gmail/enums.py
Normal file
11
toolkits/gmail/arcade_gmail/enums.py
Normal 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"
|
||||
19
toolkits/gmail/arcade_gmail/exceptions.py
Normal file
19
toolkits/gmail/arcade_gmail/exceptions.py
Normal 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
|
||||
39
toolkits/gmail/arcade_gmail/tools/__init__.py
Normal file
39
toolkits/gmail/arcade_gmail/tools/__init__.py
Normal 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",
|
||||
]
|
||||
664
toolkits/gmail/arcade_gmail/tools/gmail.py
Normal file
664
toolkits/gmail/arcade_gmail/tools/gmail.py
Normal 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}
|
||||
509
toolkits/gmail/arcade_gmail/utils.py
Normal file
509
toolkits/gmail/arcade_gmail/utils.py
Normal 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", []))
|
||||
431
toolkits/gmail/evals/eval_google_gmail.py
Normal file
431
toolkits/gmail/evals/eval_google_gmail.py
Normal 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
|
||||
64
toolkits/gmail/pyproject.toml
Normal file
64
toolkits/gmail/pyproject.toml
Normal 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",]
|
||||
0
toolkits/gmail/tests/__init__.py
Normal file
0
toolkits/gmail/tests/__init__.py
Normal file
951
toolkits/gmail/tests/test_gmail.py
Normal file
951
toolkits/gmail/tests/test_gmail.py
Normal 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) "ArcadeAI" with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,",
|
||||
"payload": {
|
||||
"partId": "",
|
||||
"mimeType": "text/plain",
|
||||
"filename": "",
|
||||
"headers": [
|
||||
{"name": "Delivered-To", "value": "example@arcade-ai.com"},
|
||||
{"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"},
|
||||
{"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"},
|
||||
{"name": "To", "value": "example@arcade-ai.com"},
|
||||
{
|
||||
"name": "Subject",
|
||||
"value": "[GitHub] Your personal access token (classic) has expired",
|
||||
},
|
||||
],
|
||||
"body": {
|
||||
"size": 605,
|
||||
"data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=",
|
||||
},
|
||||
},
|
||||
"sizeEstimate": 4512,
|
||||
"historyId": "5508",
|
||||
"internalDate": "1726355557000",
|
||||
}
|
||||
|
||||
# Setup mocking
|
||||
mock_service = MagicMock()
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
# Mock the response from the Gmail list messages API
|
||||
mock_service.users().messages().list().execute.return_value = mock_messages_list_response
|
||||
|
||||
# Mock the response from the Gmail get messages API
|
||||
mock_service.users().messages().get().execute.return_value = mock_messages_get_response
|
||||
|
||||
# Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock
|
||||
mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response)
|
||||
|
||||
# Test happy path
|
||||
result = await list_emails_by_header(
|
||||
context=mock_context, sender="noreply@github.com", max_results=2
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "emails" in result
|
||||
assert len(result["emails"]) == 2
|
||||
assert all("id" in email and "subject" in email for email in result["emails"])
|
||||
|
||||
# Test http error
|
||||
mock_service.users().messages().list().execute.side_effect = HttpError(
|
||||
resp=MagicMock(status=400),
|
||||
content=b'{"error": {"message": "Invalid request"}}',
|
||||
)
|
||||
|
||||
with pytest.raises(ToolExecutionError):
|
||||
await list_emails_by_header(
|
||||
context=mock_context, sender="noreply@github.com", max_results=2
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("arcade_gmail.tools.gmail._build_gmail_service")
|
||||
@patch("arcade_gmail.tools.gmail.parse_plain_text_email")
|
||||
async def test_get_emails(mock_parse_plain_text_email, mock_build, mock_context):
|
||||
# Setup test data
|
||||
mock_messages_list_response = {
|
||||
"messages": [
|
||||
{"id": "191fbc8ddce0f433", "threadId": "191fbc8ddce0f433"},
|
||||
],
|
||||
"nextPageToken": "00755945214480102915",
|
||||
"resultSizeEstimate": 1,
|
||||
}
|
||||
mock_messages_get_response = {
|
||||
"id": "191f2cf4d24bf23d",
|
||||
"threadId": "191f2cf4d24bf23d",
|
||||
"labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "INBOX"],
|
||||
"snippet": "Hey User, Your personal access token (classic) "ArcadeAI" with admin:enterprise, admin:gpg_key, admin:org, admin:org_hook, admin:public_key, admin:repo_hook, admin:ssh_signing_key,",
|
||||
"payload": {
|
||||
"partId": "",
|
||||
"mimeType": "text/plain",
|
||||
"filename": "",
|
||||
"headers": [
|
||||
{"name": "Delivered-To", "value": "example@arcade-ai.com"},
|
||||
{"name": "Date", "value": "Sat, 14 Sep 2024 16:12:37 -0700"},
|
||||
{"name": "From", "value": "GitHub \u003cnoreply@github.com\u003e"},
|
||||
{"name": "To", "value": "example@arcade-ai.com"},
|
||||
{
|
||||
"name": "Subject",
|
||||
"value": "[GitHub] Your personal access token (classic) has expired",
|
||||
},
|
||||
],
|
||||
"body": {
|
||||
"size": 605,
|
||||
"data": "SGV5IEBFcmljR3VzdGluLA0KDQpZb3VyIHBlcnNvbmFsIGFjY2VzcyB0b2tlbiAoY2xhc3NpYykgIkFyY2FkZUFJIiB3aXRoIGFkbWluOmVudGVycHJpc2UsIGFkbWluOmdwZ19rZXksIGFkbWluOm9yZywgYWRtaW46b3JnX2hvb2ssIGFkbWluOnB1YmxpY19rZXksIGFkbWluOnJlcG9faG9vaywgYWRtaW46c3NoX3NpZ25pbmdfa2V5LCBhdWRpdF9sb2csIGNvZGVzcGFjZSwgY29waWxvdCwgZGVsZXRlOnBhY2thZ2VzLCBkZWxldGVfcmVwbywgZ2lzdCwgbm90aWZpY2F0aW9ucywgcHJvamVjdCwgcmVwbywgdXNlciwgd29ya2Zsb3csIHdyaXRlOmRpc2N1c3Npb24sIGFuZCB3cml0ZTpwYWNrYWdlcyBzY29wZXMgaGFzIGV4cGlyZWQuDQoNCklmIHRoaXMgdG9rZW4gaXMgc3RpbGwgbmVlZGVkLCB2aXNpdCBodHRwczovL2dpdGh1Yi5jb20vc2V0dGluZ3MvdG9rZW5zLzE3MTM2OTg2MTMvcmVnZW5lcmF0ZSB0byBnZW5lcmF0ZSBhbiBlcXVpdmFsZW50Lg0KDQpJZiB5b3UgcnVuIGludG8gcHJvYmxlbXMsIHBsZWFzZSBjb250YWN0IHN1cHBvcnQgYnkgdmlzaXRpbmcgaHR0cHM6Ly9naXRodWIuY29tL2NvbnRhY3QNCg0KVGhhbmtzLA0KVGhlIEdpdEh1YiBUZWFtDQo=",
|
||||
},
|
||||
},
|
||||
"sizeEstimate": 4512,
|
||||
"historyId": "5508",
|
||||
"internalDate": "1726355557000",
|
||||
}
|
||||
|
||||
# Setup mocking
|
||||
mock_service = MagicMock()
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
# Mock the response from the Gmail list messages API
|
||||
mock_service.users().messages().list().execute.return_value = mock_messages_list_response
|
||||
|
||||
# Mock the Gmail get messages API
|
||||
mock_service.users().messages().get().execute.return_value = mock_messages_get_response
|
||||
|
||||
# Mock the parse_plain_text_email function since parse_plain_text_email doesn't accept object of type MagicMock
|
||||
mock_parse_plain_text_email.return_value = parse_plain_text_email(mock_messages_get_response)
|
||||
|
||||
# Test happy path
|
||||
result = await list_emails(context=mock_context, n_emails=1)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "emails" in result
|
||||
assert len(result["emails"]) == 1
|
||||
assert "id" in result["emails"][0]
|
||||
assert "subject" in result["emails"][0]
|
||||
assert "date" in result["emails"][0]
|
||||
assert "body" in result["emails"][0]
|
||||
|
||||
# Test http error
|
||||
mock_service.users().messages().list().execute.side_effect = HttpError(
|
||||
resp=MagicMock(status=400),
|
||||
content=b'{"error": {"message": "Invalid request"}}',
|
||||
)
|
||||
|
||||
with pytest.raises(ToolExecutionError):
|
||||
await list_emails(context=mock_context, n_emails=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("arcade_gmail.tools.gmail._build_gmail_service")
|
||||
async def test_trash_email(mock_build, mock_context):
|
||||
mock_service = MagicMock()
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
# Test happy path
|
||||
email_id = "123456"
|
||||
result = await trash_email(context=mock_context, email_id=email_id)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "id" in result
|
||||
assert "thread_id" in result
|
||||
assert "subject" in result
|
||||
assert "body" in result
|
||||
|
||||
# Test http error
|
||||
mock_service.users().messages().trash().execute.side_effect = HttpError(
|
||||
resp=MagicMock(status=400),
|
||||
content=b'{"error": {"message": "Email not found"}}',
|
||||
)
|
||||
|
||||
with pytest.raises(ToolExecutionError):
|
||||
await trash_email(context=mock_context, email_id="nonexistent_email")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("arcade_gmail.tools.gmail._build_gmail_service")
|
||||
async def test_search_threads(mock_build, mock_context):
|
||||
mock_service = MagicMock()
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
# Setup mock response data
|
||||
mock_threads_list_response = {
|
||||
"threads": [
|
||||
{
|
||||
"id": "thread1",
|
||||
"snippet": "Thread snippet 1",
|
||||
},
|
||||
{
|
||||
"id": "thread2",
|
||||
"snippet": "Thread snippet 2",
|
||||
},
|
||||
],
|
||||
"nextPageToken": "next_token_123",
|
||||
"resultSizeEstimate": 2,
|
||||
}
|
||||
|
||||
# Mock the Gmail API threads().list() method
|
||||
mock_service.users().threads().list().execute.return_value = mock_threads_list_response
|
||||
|
||||
# Test happy path
|
||||
result = await search_threads(
|
||||
context=mock_context,
|
||||
sender="test@example.com",
|
||||
max_results=2,
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "threads" in result
|
||||
assert len(result["threads"]) == 2
|
||||
assert result["threads"][0]["id"] == "thread1"
|
||||
assert "next_page_token" in result
|
||||
|
||||
# Test error handling
|
||||
mock_service.users().threads().list().execute.side_effect = HttpError(
|
||||
resp=MagicMock(status=400),
|
||||
content=b'{"error": {"message": "Invalid request"}}',
|
||||
)
|
||||
|
||||
with pytest.raises(ToolExecutionError):
|
||||
await search_threads(
|
||||
context=mock_context,
|
||||
sender="test@example.com",
|
||||
max_results=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("arcade_gmail.tools.gmail._build_gmail_service")
|
||||
async def test_list_threads(mock_build, mock_context):
|
||||
mock_service = MagicMock()
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
# Setup mock response data
|
||||
mock_threads_list_response = {
|
||||
"threads": [
|
||||
{
|
||||
"id": "thread1",
|
||||
"snippet": "Thread snippet 1",
|
||||
},
|
||||
{
|
||||
"id": "thread2",
|
||||
"snippet": "Thread snippet 2",
|
||||
},
|
||||
],
|
||||
"nextPageToken": "next_token_123",
|
||||
"resultSizeEstimate": 2,
|
||||
}
|
||||
|
||||
# Mock the Gmail API threads().list() method
|
||||
mock_service.users().threads().list().execute.return_value = mock_threads_list_response
|
||||
|
||||
# Test happy path
|
||||
result = await list_threads(
|
||||
context=mock_context,
|
||||
max_results=2,
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "threads" in result
|
||||
assert len(result["threads"]) == 2
|
||||
assert result["threads"][0]["id"] == "thread1"
|
||||
assert "next_page_token" in result
|
||||
|
||||
# Test error handling
|
||||
mock_service.users().threads().list().execute.side_effect = HttpError(
|
||||
resp=MagicMock(status=400),
|
||||
content=b'{"error": {"message": "Invalid request"}}',
|
||||
)
|
||||
|
||||
with pytest.raises(ToolExecutionError):
|
||||
await list_threads(
|
||||
context=mock_context,
|
||||
max_results=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("arcade_gmail.tools.gmail._build_gmail_service")
|
||||
async def test_get_thread(mock_build, mock_context):
|
||||
mock_service = MagicMock()
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
# Setup mock response data
|
||||
mock_thread_get_response = {
|
||||
"id": "thread1",
|
||||
"messages": [
|
||||
{
|
||||
"id": "message1",
|
||||
"snippet": "Message snippet 1",
|
||||
},
|
||||
{
|
||||
"id": "message2",
|
||||
"snippet": "Message snippet 2",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Mock the Gmail API threads().get() method
|
||||
mock_service.users().threads().get().execute.return_value = mock_thread_get_response
|
||||
|
||||
# Test happy path
|
||||
result = await get_thread(
|
||||
context=mock_context,
|
||||
thread_id="thread1",
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "id" in result
|
||||
assert result["id"] == "thread1"
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) == 2
|
||||
assert result["messages"][0]["id"] == "message1"
|
||||
|
||||
# Test error handling
|
||||
mock_service.users().threads().get().execute.side_effect = HttpError(
|
||||
resp=MagicMock(status=404),
|
||||
content=b'{"error": {"message": "Thread not found"}}',
|
||||
)
|
||||
|
||||
with pytest.raises(ToolExecutionError):
|
||||
await get_thread(
|
||||
context=mock_context,
|
||||
thread_id="invalid_thread",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"reply_to_whom, expected_to, expected_cc",
|
||||
[
|
||||
(
|
||||
GmailReplyToWhom.EVERY_RECIPIENT,
|
||||
"sender@example.com, to1@example.com, to2@example.com",
|
||||
"cc1@example.com, cc2@example.com",
|
||||
),
|
||||
(
|
||||
GmailReplyToWhom.ONLY_THE_SENDER,
|
||||
"sender@example.com",
|
||||
"",
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch("arcade_gmail.tools.gmail._build_gmail_service")
|
||||
async def test_reply_to_email(mock_build, reply_to_whom, expected_to, expected_cc, mock_context):
|
||||
mock_service = MagicMock()
|
||||
mock_build.return_value = mock_service
|
||||
|
||||
original_message = {
|
||||
"id": "id123456",
|
||||
"threadId": "thread123456",
|
||||
"payload": {
|
||||
"headers": [
|
||||
{"name": "Message-ID", "value": "id123456"},
|
||||
{"name": "Subject", "value": "test"},
|
||||
{"name": "From", "value": "sender@example.com"},
|
||||
{"name": "To", "value": "to1@example.com, to2@example.com, test@example.com"},
|
||||
{"name": "Cc", "value": "cc1@example.com, cc2@example.com"},
|
||||
{"name": "References", "value": "thread123456"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
mock_service.users().getProfile().execute.return_value = {"emailAddress": "test@example.com"}
|
||||
mock_service.users().messages().get().execute.return_value = original_message
|
||||
|
||||
result = await reply_to_email(
|
||||
context=mock_context,
|
||||
body="test",
|
||||
reply_to_message_id="id123456",
|
||||
reply_to_whom=reply_to_whom,
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "url" in result
|
||||
|
||||
replying_to = parse_multipart_email(original_message)
|
||||
expected_body = build_reply_body("test", replying_to)
|
||||
|
||||
expected_message = EmailMessage()
|
||||
expected_message.set_content(expected_body)
|
||||
expected_message["To"] = expected_to
|
||||
expected_message["Subject"] = "Re: test"
|
||||
if expected_cc:
|
||||
expected_message["Cc"] = expected_cc
|
||||
expected_message["In-Reply-To"] = "id123456"
|
||||
expected_message["References"] = "id123456, thread123456"
|
||||
|
||||
mock_service.users().messages().send.assert_called_once_with(
|
||||
userId="me",
|
||||
body={
|
||||
"raw": urlsafe_b64encode(expected_message.as_bytes()).decode(),
|
||||
"threadId": "thread123456",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_parse_multipart_email_full():
|
||||
"""
|
||||
Test parsing a multipart email with both plain text and HTML bodies.
|
||||
"""
|
||||
email_data = {
|
||||
"id": "email123",
|
||||
"threadId": "thread123",
|
||||
"labelIds": ["INBOX", "UNREAD"],
|
||||
"historyId": "history123",
|
||||
"snippet": "This is a test email.",
|
||||
"payload": {
|
||||
"headers": [
|
||||
{"name": "To", "value": "recipient@example.com"},
|
||||
{"name": "From", "value": "sender@example.com"},
|
||||
{"name": "Subject", "value": "Test Email"},
|
||||
{"name": "Date", "value": "Mon, 1 Jan 2024 10:00:00 -0000"},
|
||||
],
|
||||
"body": {"size": 100, "data": "VGhpcyBpcyBhIHRlc3QgZW1haWwu"},
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("arcade_gmail.utils._get_email_plain_text_body") as mock_plain,
|
||||
patch("arcade_gmail.utils._get_email_html_body") as mock_html,
|
||||
patch("arcade_gmail.utils._clean_email_body") as mock_clean,
|
||||
):
|
||||
# Mock the helper functions
|
||||
mock_plain.return_value = "This is a test email."
|
||||
mock_html.return_value = "<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)
|
||||
18
toolkits/google_calendar/.pre-commit-config.yaml
Normal file
18
toolkits/google_calendar/.pre-commit-config.yaml
Normal 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
|
||||
46
toolkits/google_calendar/.ruff.toml
Normal file
46
toolkits/google_calendar/.ruff.toml
Normal 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/google_calendar/Makefile
Normal file
55
toolkits/google_calendar/Makefile
Normal 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
|
||||
17
toolkits/google_calendar/arcade_google_calendar/__init__.py
Normal file
17
toolkits/google_calendar/arcade_google_calendar/__init__.py
Normal 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",
|
||||
]
|
||||
14
toolkits/google_calendar/arcade_google_calendar/enums.py
Normal file
14
toolkits/google_calendar/arcade_google_calendar/enums.py
Normal 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.
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
249
toolkits/google_calendar/arcade_google_calendar/utils.py
Normal file
249
toolkits/google_calendar/arcade_google_calendar/utils.py
Normal 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
|
||||
215
toolkits/google_calendar/evals/eval_google_calendar.py
Normal file
215
toolkits/google_calendar/evals/eval_google_calendar.py
Normal 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
|
||||
63
toolkits/google_calendar/pyproject.toml
Normal file
63
toolkits/google_calendar/pyproject.toml
Normal 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",]
|
||||
0
toolkits/google_calendar/tests/__init__.py
Normal file
0
toolkits/google_calendar/tests/__init__.py
Normal file
582
toolkits/google_calendar/tests/test_calendar.py
Normal file
582
toolkits/google_calendar/tests/test_calendar.py
Normal 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",
|
||||
)
|
||||
18
toolkits/google_contacts/.pre-commit-config.yaml
Normal file
18
toolkits/google_contacts/.pre-commit-config.yaml
Normal 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
|
||||
46
toolkits/google_contacts/.ruff.toml
Normal file
46
toolkits/google_contacts/.ruff.toml
Normal 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/google_contacts/Makefile
Normal file
55
toolkits/google_contacts/Makefile
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
DEFAULT_SEARCH_CONTACTS_LIMIT = 30
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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}
|
||||
49
toolkits/google_contacts/arcade_google_contacts/utils.py
Normal file
49
toolkits/google_contacts/arcade_google_contacts/utils.py
Normal 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", []))
|
||||
135
toolkits/google_contacts/evals/eval_google_contacts.py
Normal file
135
toolkits/google_contacts/evals/eval_google_contacts.py
Normal 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
|
||||
63
toolkits/google_contacts/pyproject.toml
Normal file
63
toolkits/google_contacts/pyproject.toml
Normal 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",]
|
||||
0
toolkits/google_contacts/tests/__init__.py
Normal file
0
toolkits/google_contacts/tests/__init__.py
Normal file
100
toolkits/google_contacts/tests/test_contacts.py
Normal file
100
toolkits/google_contacts/tests/test_contacts.py
Normal 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)
|
||||
18
toolkits/google_docs/.pre-commit-config.yaml
Normal file
18
toolkits/google_docs/.pre-commit-config.yaml
Normal 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
|
||||
46
toolkits/google_docs/.ruff.toml
Normal file
46
toolkits/google_docs/.ruff.toml
Normal 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/google_docs/Makefile
Normal file
55
toolkits/google_docs/Makefile
Normal 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
|
||||
17
toolkits/google_docs/arcade_google_docs/__init__.py
Normal file
17
toolkits/google_docs/arcade_google_docs/__init__.py
Normal 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",
|
||||
]
|
||||
24
toolkits/google_docs/arcade_google_docs/decorators.py
Normal file
24
toolkits/google_docs/arcade_google_docs/decorators.py
Normal 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
|
||||
99
toolkits/google_docs/arcade_google_docs/doc_to_html.py
Normal file
99
toolkits/google_docs/arcade_google_docs/doc_to_html.py
Normal 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
|
||||
64
toolkits/google_docs/arcade_google_docs/doc_to_markdown.py
Normal file
64
toolkits/google_docs/arcade_google_docs/doc_to_markdown.py
Normal 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 ""
|
||||
116
toolkits/google_docs/arcade_google_docs/enum.py
Normal file
116
toolkits/google_docs/arcade_google_docs/enum.py
Normal 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"
|
||||
)
|
||||
49
toolkits/google_docs/arcade_google_docs/file_picker.py
Normal file
49
toolkits/google_docs/arcade_google_docs/file_picker.py
Normal 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}"
|
||||
),
|
||||
}
|
||||
5
toolkits/google_docs/arcade_google_docs/templates.py
Normal file
5
toolkits/google_docs/arcade_google_docs/templates.py
Normal 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}"
|
||||
)
|
||||
19
toolkits/google_docs/arcade_google_docs/tools/__init__.py
Normal file
19
toolkits/google_docs/arcade_google_docs/tools/__init__.py
Normal 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",
|
||||
]
|
||||
82
toolkits/google_docs/arcade_google_docs/tools/create.py
Normal file
82
toolkits/google_docs/arcade_google_docs/tools/create.py
Normal 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",
|
||||
}
|
||||
35
toolkits/google_docs/arcade_google_docs/tools/get.py
Normal file
35
toolkits/google_docs/arcade_google_docs/tools/get.py
Normal 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)
|
||||
219
toolkits/google_docs/arcade_google_docs/tools/search.py
Normal file
219
toolkits/google_docs/arcade_google_docs/tools/search.py
Normal 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"]
|
||||
),
|
||||
},
|
||||
}
|
||||
60
toolkits/google_docs/arcade_google_docs/tools/update.py
Normal file
60
toolkits/google_docs/arcade_google_docs/tools/update.py
Normal 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)
|
||||
119
toolkits/google_docs/arcade_google_docs/utils.py
Normal file
119
toolkits/google_docs/arcade_google_docs/utils.py
Normal 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}
|
||||
967
toolkits/google_docs/conftest.py
Normal file
967
toolkits/google_docs/conftest.py
Normal 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
|
||||
384
toolkits/google_docs/evals/eval_google_docs.py
Normal file
384
toolkits/google_docs/evals/eval_google_docs.py
Normal 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
|
||||
62
toolkits/google_docs/pyproject.toml
Normal file
62
toolkits/google_docs/pyproject.toml
Normal 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",]
|
||||
0
toolkits/google_docs/tests/__init__.py
Normal file
0
toolkits/google_docs/tests/__init__.py
Normal file
10
toolkits/google_docs/tests/test_doc_to_markdown.py
Normal file
10
toolkits/google_docs/tests/test_doc_to_markdown.py
Normal 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
|
||||
179
toolkits/google_docs/tests/test_google_docs.py
Normal file
179
toolkits/google_docs/tests/test_google_docs.py
Normal 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!")
|
||||
276
toolkits/google_docs/tests/test_search.py
Normal file
276
toolkits/google_docs/tests/test_search.py
Normal 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
|
||||
18
toolkits/google_drive/.pre-commit-config.yaml
Normal file
18
toolkits/google_drive/.pre-commit-config.yaml
Normal 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
|
||||
46
toolkits/google_drive/.ruff.toml
Normal file
46
toolkits/google_drive/.ruff.toml
Normal 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/google_drive/Makefile
Normal file
55
toolkits/google_drive/Makefile
Normal 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
|
||||
3
toolkits/google_drive/arcade_google_drive/__init__.py
Normal file
3
toolkits/google_drive/arcade_google_drive/__init__.py
Normal 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"]
|
||||
116
toolkits/google_drive/arcade_google_drive/enums.py
Normal file
116
toolkits/google_drive/arcade_google_drive/enums.py
Normal 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"
|
||||
5
toolkits/google_drive/arcade_google_drive/templates.py
Normal file
5
toolkits/google_drive/arcade_google_drive/templates.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
167
toolkits/google_drive/arcade_google_drive/tools/drive.py
Normal file
167
toolkits/google_drive/arcade_google_drive/tools/drive.py
Normal 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}"
|
||||
),
|
||||
}
|
||||
114
toolkits/google_drive/arcade_google_drive/utils.py
Normal file
114
toolkits/google_drive/arcade_google_drive/utils.py
Normal 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
|
||||
197
toolkits/google_drive/conftest.py
Normal file
197
toolkits/google_drive/conftest.py
Normal 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
Loading…
Reference in a new issue