arcade-google-contacts 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arcade_google_contacts-2.0.0/.gitignore +175 -0
- arcade_google_contacts-2.0.0/.pre-commit-config.yaml +18 -0
- arcade_google_contacts-2.0.0/.ruff.toml +46 -0
- arcade_google_contacts-2.0.0/Makefile +55 -0
- arcade_google_contacts-2.0.0/PKG-INFO +23 -0
- arcade_google_contacts-2.0.0/arcade_google_contacts/__init__.py +7 -0
- arcade_google_contacts-2.0.0/arcade_google_contacts/constants.py +1 -0
- arcade_google_contacts-2.0.0/arcade_google_contacts/tools/__init__.py +7 -0
- arcade_google_contacts-2.0.0/arcade_google_contacts/tools/contacts.py +96 -0
- arcade_google_contacts-2.0.0/arcade_google_contacts/utils.py +49 -0
- arcade_google_contacts-2.0.0/evals/eval_google_contacts.py +135 -0
- arcade_google_contacts-2.0.0/pyproject.toml +63 -0
- arcade_google_contacts-2.0.0/tests/__init__.py +0 -0
- arcade_google_contacts-2.0.0/tests/test_contacts.py +100 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
credentials.yaml
|
|
3
|
+
docker/credentials.yaml
|
|
4
|
+
|
|
5
|
+
*.lock
|
|
6
|
+
|
|
7
|
+
# example data
|
|
8
|
+
examples/data
|
|
9
|
+
scratch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
docs/source
|
|
13
|
+
|
|
14
|
+
# From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
|
|
15
|
+
|
|
16
|
+
# Byte-compiled / optimized / DLL files
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.py[cod]
|
|
19
|
+
*$py.class
|
|
20
|
+
|
|
21
|
+
# C extensions
|
|
22
|
+
*.so
|
|
23
|
+
|
|
24
|
+
# Distribution / packaging
|
|
25
|
+
.Python
|
|
26
|
+
build/
|
|
27
|
+
develop-eggs/
|
|
28
|
+
dist/
|
|
29
|
+
downloads/
|
|
30
|
+
eggs/
|
|
31
|
+
.eggs/
|
|
32
|
+
lib/
|
|
33
|
+
lib64/
|
|
34
|
+
parts/
|
|
35
|
+
sdist/
|
|
36
|
+
var/
|
|
37
|
+
wheels/
|
|
38
|
+
share/python-wheels/
|
|
39
|
+
*.egg-info/
|
|
40
|
+
.installed.cfg
|
|
41
|
+
*.egg
|
|
42
|
+
MANIFEST
|
|
43
|
+
|
|
44
|
+
# PyInstaller
|
|
45
|
+
# Usually these files are written by a python script from a template
|
|
46
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
47
|
+
*.manifest
|
|
48
|
+
*.spec
|
|
49
|
+
|
|
50
|
+
# Installer logs
|
|
51
|
+
pip-log.txt
|
|
52
|
+
pip-delete-this-directory.txt
|
|
53
|
+
|
|
54
|
+
# Unit test / coverage reports
|
|
55
|
+
htmlcov/
|
|
56
|
+
.tox/
|
|
57
|
+
.nox/
|
|
58
|
+
.coverage
|
|
59
|
+
.coverage.*
|
|
60
|
+
.cache
|
|
61
|
+
nosetests.xml
|
|
62
|
+
coverage.xml
|
|
63
|
+
*.cover
|
|
64
|
+
*.py,cover
|
|
65
|
+
.hypothesis/
|
|
66
|
+
.pytest_cache/
|
|
67
|
+
cover/
|
|
68
|
+
|
|
69
|
+
# Translations
|
|
70
|
+
*.mo
|
|
71
|
+
*.pot
|
|
72
|
+
|
|
73
|
+
# Django stuff:
|
|
74
|
+
*.log
|
|
75
|
+
local_settings.py
|
|
76
|
+
db.sqlite3
|
|
77
|
+
db.sqlite3-journal
|
|
78
|
+
|
|
79
|
+
# Flask stuff:
|
|
80
|
+
instance/
|
|
81
|
+
.webassets-cache
|
|
82
|
+
|
|
83
|
+
# Scrapy stuff:
|
|
84
|
+
.scrapy
|
|
85
|
+
|
|
86
|
+
# Sphinx documentation
|
|
87
|
+
docs/_build/
|
|
88
|
+
|
|
89
|
+
# PyBuilder
|
|
90
|
+
.pybuilder/
|
|
91
|
+
target/
|
|
92
|
+
|
|
93
|
+
# Jupyter Notebook
|
|
94
|
+
.ipynb_checkpoints
|
|
95
|
+
|
|
96
|
+
# IPython
|
|
97
|
+
profile_default/
|
|
98
|
+
ipython_config.py
|
|
99
|
+
|
|
100
|
+
# pyenv
|
|
101
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
102
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
103
|
+
# .python-version
|
|
104
|
+
|
|
105
|
+
# pipenv
|
|
106
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
107
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
108
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
109
|
+
# install all needed dependencies.
|
|
110
|
+
#Pipfile.lock
|
|
111
|
+
|
|
112
|
+
# poetry
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
114
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
115
|
+
# commonly ignored for libraries.
|
|
116
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
117
|
+
poetry.lock
|
|
118
|
+
|
|
119
|
+
# pdm
|
|
120
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
121
|
+
#pdm.lock
|
|
122
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
123
|
+
# in version control.
|
|
124
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
125
|
+
.pdm.toml
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.venv
|
|
140
|
+
env/
|
|
141
|
+
venv/
|
|
142
|
+
ENV/
|
|
143
|
+
env.bak/
|
|
144
|
+
venv.bak/
|
|
145
|
+
|
|
146
|
+
# Spyder project settings
|
|
147
|
+
.spyderproject
|
|
148
|
+
.spyproject
|
|
149
|
+
|
|
150
|
+
# Rope project settings
|
|
151
|
+
.ropeproject
|
|
152
|
+
|
|
153
|
+
# mkdocs documentation
|
|
154
|
+
/site
|
|
155
|
+
|
|
156
|
+
# mypy
|
|
157
|
+
.mypy_cache/
|
|
158
|
+
.dmypy.json
|
|
159
|
+
dmypy.json
|
|
160
|
+
|
|
161
|
+
# Pyre type checker
|
|
162
|
+
.pyre/
|
|
163
|
+
|
|
164
|
+
# pytype static type analyzer
|
|
165
|
+
.pytype/
|
|
166
|
+
|
|
167
|
+
# Cython debug symbols
|
|
168
|
+
cython_debug/
|
|
169
|
+
|
|
170
|
+
# PyCharm
|
|
171
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
172
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
173
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
174
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
175
|
+
#.idea/
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
files: ^.*/google_contacts/.*
|
|
2
|
+
repos:
|
|
3
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
4
|
+
rev: "v4.4.0"
|
|
5
|
+
hooks:
|
|
6
|
+
- id: check-case-conflict
|
|
7
|
+
- id: check-merge-conflict
|
|
8
|
+
- id: check-toml
|
|
9
|
+
- id: check-yaml
|
|
10
|
+
- id: end-of-file-fixer
|
|
11
|
+
- id: trailing-whitespace
|
|
12
|
+
|
|
13
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
14
|
+
rev: v0.6.7
|
|
15
|
+
hooks:
|
|
16
|
+
- id: ruff
|
|
17
|
+
args: [--fix]
|
|
18
|
+
- id: ruff-format
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
target-version = "py310"
|
|
2
|
+
line-length = 100
|
|
3
|
+
fix = true
|
|
4
|
+
|
|
5
|
+
[lint]
|
|
6
|
+
select = [
|
|
7
|
+
# flake8-2020
|
|
8
|
+
"YTT",
|
|
9
|
+
# flake8-bandit
|
|
10
|
+
"S",
|
|
11
|
+
# flake8-bugbear
|
|
12
|
+
"B",
|
|
13
|
+
# flake8-builtins
|
|
14
|
+
"A",
|
|
15
|
+
# flake8-comprehensions
|
|
16
|
+
"C4",
|
|
17
|
+
# flake8-debugger
|
|
18
|
+
"T10",
|
|
19
|
+
# flake8-simplify
|
|
20
|
+
"SIM",
|
|
21
|
+
# isort
|
|
22
|
+
"I",
|
|
23
|
+
# mccabe
|
|
24
|
+
"C90",
|
|
25
|
+
# pycodestyle
|
|
26
|
+
"E", "W",
|
|
27
|
+
# pyflakes
|
|
28
|
+
"F",
|
|
29
|
+
# pygrep-hooks
|
|
30
|
+
"PGH",
|
|
31
|
+
# pyupgrade
|
|
32
|
+
"UP",
|
|
33
|
+
# ruff
|
|
34
|
+
"RUF",
|
|
35
|
+
# tryceratops
|
|
36
|
+
"TRY",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[lint.per-file-ignores]
|
|
40
|
+
"*" = ["TRY003", "B904"]
|
|
41
|
+
"**/tests/*" = ["S101", "E501"]
|
|
42
|
+
"**/evals/*" = ["S101", "E501"]
|
|
43
|
+
|
|
44
|
+
[format]
|
|
45
|
+
preview = true
|
|
46
|
+
skip-magic-trailing-comma = false
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
.PHONY: help
|
|
2
|
+
|
|
3
|
+
help:
|
|
4
|
+
@echo "🛠️ github Commands:\n"
|
|
5
|
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
|
6
|
+
|
|
7
|
+
.PHONY: install
|
|
8
|
+
install: ## Install the uv environment and install all packages with dependencies
|
|
9
|
+
@echo "🚀 Creating virtual environment and installing all packages using uv"
|
|
10
|
+
@uv sync --active --all-extras --no-sources
|
|
11
|
+
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
|
|
12
|
+
@echo "✅ All packages and dependencies installed via uv"
|
|
13
|
+
|
|
14
|
+
.PHONY: install-local
|
|
15
|
+
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
|
|
16
|
+
@echo "🚀 Creating virtual environment and installing all packages using uv"
|
|
17
|
+
@uv sync --active --all-extras
|
|
18
|
+
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
|
|
19
|
+
@echo "✅ All packages and dependencies installed via uv"
|
|
20
|
+
|
|
21
|
+
.PHONY: build
|
|
22
|
+
build: clean-build ## Build wheel file using poetry
|
|
23
|
+
@echo "🚀 Creating wheel file"
|
|
24
|
+
uv build
|
|
25
|
+
|
|
26
|
+
.PHONY: clean-build
|
|
27
|
+
clean-build: ## clean build artifacts
|
|
28
|
+
@echo "🗑️ Cleaning dist directory"
|
|
29
|
+
rm -rf dist
|
|
30
|
+
|
|
31
|
+
.PHONY: test
|
|
32
|
+
test: ## Test the code with pytest
|
|
33
|
+
@echo "🚀 Testing code: Running pytest"
|
|
34
|
+
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
|
35
|
+
|
|
36
|
+
.PHONY: coverage
|
|
37
|
+
coverage: ## Generate coverage report
|
|
38
|
+
@echo "coverage report"
|
|
39
|
+
@uv run --no-sources coverage report
|
|
40
|
+
@echo "Generating coverage report"
|
|
41
|
+
@uv run --no-sources coverage html
|
|
42
|
+
|
|
43
|
+
.PHONY: bump-version
|
|
44
|
+
bump-version: ## Bump the version in the pyproject.toml file by a patch version
|
|
45
|
+
@echo "🚀 Bumping version in pyproject.toml"
|
|
46
|
+
uv version --no-sources --bump patch
|
|
47
|
+
|
|
48
|
+
.PHONY: check
|
|
49
|
+
check: ## Run code quality tools.
|
|
50
|
+
@if [ -f .pre-commit-config.yaml ]; then\
|
|
51
|
+
echo "🚀 Linting code: Running pre-commit";\
|
|
52
|
+
uv run --no-sources pre-commit run -a;\
|
|
53
|
+
fi
|
|
54
|
+
@echo "🚀 Static type checking: Running mypy"
|
|
55
|
+
@uv run --no-sources mypy --config-file=pyproject.toml
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arcade_google_contacts
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Arcade.dev LLM tools for Google Contacts
|
|
5
|
+
Author-email: Arcade <dev@arcade.dev>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: arcade-tdk<3.0.0,>=2.0.0
|
|
8
|
+
Requires-Dist: google-api-core<3.0.0,>=2.19.1
|
|
9
|
+
Requires-Dist: google-api-python-client<3.0.0,>=2.137.0
|
|
10
|
+
Requires-Dist: google-auth-httplib2<1.0.0,>=0.2.0
|
|
11
|
+
Requires-Dist: google-auth<3.0.0,>=2.32.0
|
|
12
|
+
Requires-Dist: googleapis-common-protos<2.0.0,>=1.63.2
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: arcade-ai[evals]<3.0.0,>=2.0.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: arcade-serve<3.0.0,>=2.0.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: mypy<1.6.0,>=1.5.1; extra == 'dev'
|
|
17
|
+
Requires-Dist: pre-commit<3.5.0,>=3.4.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio<0.25.0,>=0.24.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov<4.1.0,>=4.0.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-mock<3.12.0,>=3.11.1; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest<8.4.0,>=8.3.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff<0.8.0,>=0.7.4; extra == 'dev'
|
|
23
|
+
Requires-Dist: tox<4.12.0,>=4.11.1; extra == 'dev'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DEFAULT_SEARCH_CONTACTS_LIMIT = 30
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from arcade_tdk import ToolContext, tool
|
|
5
|
+
from arcade_tdk.auth import Google
|
|
6
|
+
|
|
7
|
+
from arcade_google_contacts.constants import DEFAULT_SEARCH_CONTACTS_LIMIT
|
|
8
|
+
from arcade_google_contacts.utils import build_people_service, search_contacts
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _warmup_cache(service) -> None: # type: ignore[no-untyped-def]
|
|
12
|
+
"""
|
|
13
|
+
Warm-up the search cache for contacts by sending a request with an empty query.
|
|
14
|
+
This ensures that the lazy cache is updated for both primary contacts and other contacts.
|
|
15
|
+
This is unfortunately a real thing: https://developers.google.com/people/v1/contacts#search_the_users_contacts
|
|
16
|
+
"""
|
|
17
|
+
service.people().searchContacts(query="", pageSize=1, readMask="names,emailAddresses").execute()
|
|
18
|
+
await asyncio.sleep(3) # TODO experiment with this value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"]))
|
|
22
|
+
async def search_contacts_by_email(
|
|
23
|
+
context: ToolContext,
|
|
24
|
+
email: Annotated[str, "The email address to search for"],
|
|
25
|
+
limit: Annotated[
|
|
26
|
+
int | None,
|
|
27
|
+
"The maximum number of contacts to return (30 is the max allowed by Google API)",
|
|
28
|
+
] = DEFAULT_SEARCH_CONTACTS_LIMIT,
|
|
29
|
+
) -> Annotated[dict, "A dictionary containing the list of matching contacts"]:
|
|
30
|
+
"""
|
|
31
|
+
Search the user's contacts in Google Contacts by email address.
|
|
32
|
+
"""
|
|
33
|
+
service = build_people_service(context.get_auth_token_or_empty())
|
|
34
|
+
# Warm-up the cache before performing search.
|
|
35
|
+
# TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently
|
|
36
|
+
await _warmup_cache(service)
|
|
37
|
+
|
|
38
|
+
return {"contacts": search_contacts(service, email, limit)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"]))
|
|
42
|
+
async def search_contacts_by_name(
|
|
43
|
+
context: ToolContext,
|
|
44
|
+
name: Annotated[str, "The full name to search for"],
|
|
45
|
+
limit: Annotated[
|
|
46
|
+
int | None,
|
|
47
|
+
"The maximum number of contacts to return (30 is the max allowed by Google API)",
|
|
48
|
+
] = DEFAULT_SEARCH_CONTACTS_LIMIT,
|
|
49
|
+
) -> Annotated[dict, "A dictionary containing the list of matching contacts"]:
|
|
50
|
+
"""
|
|
51
|
+
Search the user's contacts in Google Contacts by name.
|
|
52
|
+
"""
|
|
53
|
+
service = build_people_service(context.get_auth_token_or_empty())
|
|
54
|
+
# Warm-up the cache before performing search.
|
|
55
|
+
# TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently
|
|
56
|
+
await _warmup_cache(service)
|
|
57
|
+
return {"contacts": search_contacts(service, name, limit)}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts"]))
|
|
61
|
+
async def create_contact(
|
|
62
|
+
context: ToolContext,
|
|
63
|
+
given_name: Annotated[str, "The given name of the contact"],
|
|
64
|
+
family_name: Annotated[str | None, "The optional family name of the contact"],
|
|
65
|
+
email: Annotated[str | None, "The optional email address of the contact"],
|
|
66
|
+
) -> Annotated[dict, "A dictionary containing the details of the created contact"]:
|
|
67
|
+
"""
|
|
68
|
+
Create a new contact record in Google Contacts.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
```
|
|
72
|
+
create_contact(given_name="Alice")
|
|
73
|
+
create_contact(given_name="Alice", family_name="Smith")
|
|
74
|
+
create_contact(given_name="Alice", email="alice@example.com")
|
|
75
|
+
```
|
|
76
|
+
"""
|
|
77
|
+
# Build the People API service
|
|
78
|
+
service = build_people_service(context.get_auth_token_or_empty())
|
|
79
|
+
|
|
80
|
+
# Construct the person payload with the specified names
|
|
81
|
+
name_body = {"givenName": given_name}
|
|
82
|
+
if family_name:
|
|
83
|
+
name_body["familyName"] = family_name
|
|
84
|
+
contact_body = {"names": [name_body]}
|
|
85
|
+
if email:
|
|
86
|
+
contact_body["emailAddresses"] = [{"value": email, "type": "work"}]
|
|
87
|
+
|
|
88
|
+
# Create the contact. The personFields parameter specifies what information
|
|
89
|
+
# should be returned. Here, we return names and emailAddresses.
|
|
90
|
+
created_contact = (
|
|
91
|
+
service.people()
|
|
92
|
+
.createContact(body=contact_body, personFields="names,emailAddresses")
|
|
93
|
+
.execute()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return {"contact": created_contact}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
from google.oauth2.credentials import Credentials
|
|
5
|
+
from googleapiclient.discovery import Resource, build
|
|
6
|
+
|
|
7
|
+
from arcade_google_contacts.constants import DEFAULT_SEARCH_CONTACTS_LIMIT
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.DEBUG,
|
|
11
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_people_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
|
|
18
|
+
"""
|
|
19
|
+
Build a People service object.
|
|
20
|
+
"""
|
|
21
|
+
auth_token = auth_token or ""
|
|
22
|
+
return build("people", "v1", credentials=Credentials(auth_token))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def search_contacts(service: Any, query: str, limit: int | None) -> list[dict[str, Any]]:
|
|
26
|
+
"""
|
|
27
|
+
Search the user's contacts in Google Contacts.
|
|
28
|
+
"""
|
|
29
|
+
response = (
|
|
30
|
+
service.people()
|
|
31
|
+
.searchContacts(
|
|
32
|
+
query=query,
|
|
33
|
+
pageSize=limit or DEFAULT_SEARCH_CONTACTS_LIMIT,
|
|
34
|
+
readMask=",".join([
|
|
35
|
+
"names",
|
|
36
|
+
"nicknames",
|
|
37
|
+
"emailAddresses",
|
|
38
|
+
"phoneNumbers",
|
|
39
|
+
"addresses",
|
|
40
|
+
"organizations",
|
|
41
|
+
"biographies",
|
|
42
|
+
"urls",
|
|
43
|
+
"userDefined",
|
|
44
|
+
]),
|
|
45
|
+
)
|
|
46
|
+
.execute()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return cast(list[dict[str, Any]], response.get("results", []))
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from arcade_evals import (
|
|
2
|
+
BinaryCritic,
|
|
3
|
+
EvalRubric,
|
|
4
|
+
EvalSuite,
|
|
5
|
+
ExpectedToolCall,
|
|
6
|
+
tool_eval,
|
|
7
|
+
)
|
|
8
|
+
from arcade_tdk import ToolCatalog
|
|
9
|
+
|
|
10
|
+
import arcade_google_contacts
|
|
11
|
+
from arcade_google_contacts.tools import (
|
|
12
|
+
create_contact,
|
|
13
|
+
search_contacts_by_email,
|
|
14
|
+
search_contacts_by_name,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Evaluation rubric
|
|
18
|
+
rubric = EvalRubric(
|
|
19
|
+
fail_threshold=0.9,
|
|
20
|
+
warn_threshold=0.95,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
catalog = ToolCatalog()
|
|
24
|
+
catalog.add_module(arcade_google_contacts)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@tool_eval()
|
|
28
|
+
def contacts_eval_suite() -> EvalSuite:
|
|
29
|
+
"""Create an evaluation suite for Google Contacts tools."""
|
|
30
|
+
suite = EvalSuite(
|
|
31
|
+
name="Google Contacts Tools Evaluation",
|
|
32
|
+
system_message="You are an AI assistant that can manage Google Contacts using the provided tools.",
|
|
33
|
+
catalog=catalog,
|
|
34
|
+
rubric=rubric,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
suite.add_case(
|
|
38
|
+
name="Search contacts by name",
|
|
39
|
+
user_message="Find my contact Bob",
|
|
40
|
+
expected_tool_calls=[
|
|
41
|
+
ExpectedToolCall(
|
|
42
|
+
func=search_contacts_by_name,
|
|
43
|
+
args={
|
|
44
|
+
"name": "Bob",
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
suite.add_case(
|
|
51
|
+
name="Search contacts by email",
|
|
52
|
+
user_message="Find my contact alice@example.com",
|
|
53
|
+
expected_tool_calls=[
|
|
54
|
+
ExpectedToolCall(
|
|
55
|
+
func=search_contacts_by_email,
|
|
56
|
+
args={
|
|
57
|
+
"email": "alice@example.com",
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
suite.add_case(
|
|
64
|
+
name="Search contacts with query and limit",
|
|
65
|
+
user_message="Find 5 contacts whose names include 'Alice'",
|
|
66
|
+
expected_tool_calls=[
|
|
67
|
+
ExpectedToolCall(
|
|
68
|
+
func=search_contacts_by_name,
|
|
69
|
+
args={
|
|
70
|
+
"name": "Alice",
|
|
71
|
+
"limit": 5,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
],
|
|
75
|
+
critics=[
|
|
76
|
+
BinaryCritic(critic_field="query", weight=0.5),
|
|
77
|
+
BinaryCritic(critic_field="limit", weight=0.5),
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
suite.add_case(
|
|
82
|
+
name="Create new contact with only given name",
|
|
83
|
+
user_message="Create a new contact for Alice",
|
|
84
|
+
expected_tool_calls=[
|
|
85
|
+
ExpectedToolCall(
|
|
86
|
+
func=create_contact,
|
|
87
|
+
args={
|
|
88
|
+
"given_name": "Alice",
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
],
|
|
92
|
+
critics=[
|
|
93
|
+
BinaryCritic(critic_field="given_name", weight=1.0),
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
suite.add_case(
|
|
98
|
+
name="Create new contact with only email (infer name from email)",
|
|
99
|
+
user_message="Create a new contact for alice@example.com",
|
|
100
|
+
expected_tool_calls=[
|
|
101
|
+
ExpectedToolCall(
|
|
102
|
+
func=create_contact,
|
|
103
|
+
args={
|
|
104
|
+
"given_name": "Alice",
|
|
105
|
+
"email": "alice@example.com",
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
],
|
|
109
|
+
critics=[
|
|
110
|
+
BinaryCritic(critic_field="email", weight=0.5),
|
|
111
|
+
BinaryCritic(critic_field="given_name", weight=0.5),
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
suite.add_case(
|
|
116
|
+
name="Create new contact with full name and email",
|
|
117
|
+
user_message="Create a contact for Bob Smith (bob.smith@example.com)",
|
|
118
|
+
expected_tool_calls=[
|
|
119
|
+
ExpectedToolCall(
|
|
120
|
+
func=create_contact,
|
|
121
|
+
args={
|
|
122
|
+
"given_name": "Bob",
|
|
123
|
+
"family_name": "Smith",
|
|
124
|
+
"email": "bob.smith@example.com",
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
],
|
|
128
|
+
critics=[
|
|
129
|
+
BinaryCritic(critic_field="given_name", weight=0.33),
|
|
130
|
+
BinaryCritic(critic_field="family_name", weight=0.33),
|
|
131
|
+
BinaryCritic(critic_field="email", weight=0.34),
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return suite
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [ "hatchling",]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "arcade_google_contacts"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Arcade.dev LLM tools for Google Contacts"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"arcade-tdk>=2.0.0,<3.0.0",
|
|
12
|
+
"google-api-core>=2.19.1,<3.0.0",
|
|
13
|
+
"google-api-python-client>=2.137.0,<3.0.0",
|
|
14
|
+
"google-auth>=2.32.0,<3.0.0",
|
|
15
|
+
"google-auth-httplib2>=0.2.0,<1.0.0",
|
|
16
|
+
"googleapis-common-protos>=1.63.2,<2.0.0",
|
|
17
|
+
]
|
|
18
|
+
[[project.authors]]
|
|
19
|
+
name = "Arcade"
|
|
20
|
+
email = "dev@arcade.dev"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"arcade-ai[evals]>=2.0.0,<3.0.0",
|
|
26
|
+
"arcade-serve>=2.0.0,<3.0.0",
|
|
27
|
+
"pytest>=8.3.0,<8.4.0",
|
|
28
|
+
"pytest-cov>=4.0.0,<4.1.0",
|
|
29
|
+
"pytest-mock>=3.11.1,<3.12.0",
|
|
30
|
+
"pytest-asyncio>=0.24.0,<0.25.0",
|
|
31
|
+
"mypy>=1.5.1,<1.6.0",
|
|
32
|
+
"pre-commit>=3.4.0,<3.5.0",
|
|
33
|
+
"tox>=4.11.1,<4.12.0",
|
|
34
|
+
"ruff>=0.7.4,<0.8.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Use local path sources for arcade libs when working locally
|
|
38
|
+
[tool.uv.sources]
|
|
39
|
+
arcade-ai = { path = "../../", editable = true }
|
|
40
|
+
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
|
|
41
|
+
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
files = [ "arcade_google_contacts/**/*.py",]
|
|
46
|
+
python_version = "3.10"
|
|
47
|
+
disallow_untyped_defs = "True"
|
|
48
|
+
disallow_any_unimported = "True"
|
|
49
|
+
no_implicit_optional = "True"
|
|
50
|
+
check_untyped_defs = "True"
|
|
51
|
+
warn_return_any = "True"
|
|
52
|
+
warn_unused_ignores = "True"
|
|
53
|
+
show_error_codes = "True"
|
|
54
|
+
ignore_missing_imports = "True"
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = [ "tests",]
|
|
58
|
+
|
|
59
|
+
[tool.coverage.report]
|
|
60
|
+
skip_empty = true
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = [ "arcade_google_contacts",]
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from arcade_tdk import ToolContext
|
|
5
|
+
|
|
6
|
+
from arcade_google_contacts.tools import create_contact
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def mock_context():
|
|
11
|
+
context = AsyncMock(spec=ToolContext)
|
|
12
|
+
context.authorization = MagicMock()
|
|
13
|
+
context.authorization.token = "mock_token" # noqa: S105
|
|
14
|
+
return context
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.asyncio
|
|
18
|
+
async def test_create_contact_success(mock_context):
|
|
19
|
+
# Test create_contact with all parameters (given, family names and email)
|
|
20
|
+
created_contact_data = {"resourceName": "people/123", "etag": "abc"}
|
|
21
|
+
|
|
22
|
+
create_contact_call = MagicMock()
|
|
23
|
+
create_contact_call.execute.return_value = created_contact_data
|
|
24
|
+
|
|
25
|
+
people_mock = MagicMock()
|
|
26
|
+
people_mock.createContact.return_value = create_contact_call
|
|
27
|
+
|
|
28
|
+
service_mock = MagicMock()
|
|
29
|
+
service_mock.people.return_value = people_mock
|
|
30
|
+
|
|
31
|
+
with patch(
|
|
32
|
+
"arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock
|
|
33
|
+
) as mock_build:
|
|
34
|
+
result = await create_contact(
|
|
35
|
+
mock_context,
|
|
36
|
+
given_name="Alice",
|
|
37
|
+
family_name="Smith",
|
|
38
|
+
email="alice@example.com",
|
|
39
|
+
)
|
|
40
|
+
assert "contact" in result
|
|
41
|
+
assert result["contact"] == created_contact_data
|
|
42
|
+
|
|
43
|
+
# Verify that the createContact API was called with the correct body contents.
|
|
44
|
+
expected_body = {
|
|
45
|
+
"names": [{"givenName": "Alice", "familyName": "Smith"}],
|
|
46
|
+
"emailAddresses": [{"value": "alice@example.com", "type": "work"}],
|
|
47
|
+
}
|
|
48
|
+
people_mock.createContact.assert_called_once_with(
|
|
49
|
+
body=expected_body, personFields="names,emailAddresses"
|
|
50
|
+
)
|
|
51
|
+
mock_build.assert_called_once()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_create_contact_success_without_optional(mock_context):
|
|
56
|
+
# Test create_contact without optional parameters family_name and email.
|
|
57
|
+
created_contact_data = {"resourceName": "people/456", "etag": "def"}
|
|
58
|
+
|
|
59
|
+
create_contact_call = MagicMock()
|
|
60
|
+
create_contact_call.execute.return_value = created_contact_data
|
|
61
|
+
|
|
62
|
+
people_mock = MagicMock()
|
|
63
|
+
people_mock.createContact.return_value = create_contact_call
|
|
64
|
+
|
|
65
|
+
service_mock = MagicMock()
|
|
66
|
+
service_mock.people.return_value = people_mock
|
|
67
|
+
|
|
68
|
+
with patch(
|
|
69
|
+
"arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock
|
|
70
|
+
):
|
|
71
|
+
result = await create_contact(mock_context, given_name="Bob", family_name=None, email=None)
|
|
72
|
+
assert "contact" in result
|
|
73
|
+
assert result["contact"] == created_contact_data
|
|
74
|
+
|
|
75
|
+
# Expected body should only include the givenName when family_name and email are omitted.
|
|
76
|
+
expected_body = {"names": [{"givenName": "Bob"}]}
|
|
77
|
+
people_mock.createContact.assert_called_once_with(
|
|
78
|
+
body=expected_body, personFields="names,emailAddresses"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_create_contact_error(mock_context):
|
|
84
|
+
# Simulate an error thrown by createContact
|
|
85
|
+
error_call = MagicMock()
|
|
86
|
+
error_call.execute.side_effect = Exception("Create error")
|
|
87
|
+
|
|
88
|
+
people_mock = MagicMock()
|
|
89
|
+
people_mock.createContact.return_value = error_call
|
|
90
|
+
|
|
91
|
+
service_mock = MagicMock()
|
|
92
|
+
service_mock.people.return_value = people_mock
|
|
93
|
+
|
|
94
|
+
with (
|
|
95
|
+
patch(
|
|
96
|
+
"arcade_google_contacts.tools.contacts.build_people_service", return_value=service_mock
|
|
97
|
+
),
|
|
98
|
+
pytest.raises(Exception, match="Error in execution of CreateContact"),
|
|
99
|
+
):
|
|
100
|
+
await create_contact(mock_context, given_name="Alice", family_name="Doe", email=None)
|