Mateo/google calendar list (#367)

Adds "ListCalendars" to the calendar toolkit
This commit is contained in:
Mateo Torres 2025-04-18 10:05:39 -07:00 committed by GitHub
parent 2dc70a7814
commit eb6b7f3987
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 211 additions and 81 deletions

View file

@ -6,17 +6,64 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Google
from arcade.sdk.errors import RetryableToolError
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from arcade_google.models import EventVisibility, SendUpdatesOptions
from arcade_google.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=[
@ -49,15 +96,7 @@ async def create_event(
) -> Annotated[dict, "A dictionary containing the created event details"]:
"""Create a new event/meeting/sync/meetup in the specified calendar."""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
service = build_calendar_service(context.get_auth_token_or_empty())
# Get the calendar's time zone
calendar = service.calendars().get(calendarId=calendar_id).execute()
@ -121,15 +160,7 @@ async def list_events(
ending at 10:00 on September 15 would be included, but an
event starting at 17:00 on September 16 would not be included.
"""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
service = build_calendar_service(context.get_auth_token_or_empty())
# Get the calendar's time zone
calendar = service.calendars().get(calendarId=calendar_id).execute()
@ -229,15 +260,7 @@ async def update_event(
`updated_start_datetime` and `updated_end_datetime` are
independent and can be provided separately.
"""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
service = build_calendar_service(context.get_auth_token_or_empty())
calendar = service.calendars().get(calendarId="primary").execute()
time_zone = calendar["timeZone"]
@ -335,15 +358,7 @@ async def delete_event(
] = SendUpdatesOptions.ALL,
) -> Annotated[str, "A string containing the deletion confirmation message"]:
"""Delete an event from Google Calendar."""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
service = build_calendar_service(context.get_auth_token_or_empty())
service.events().delete(
calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value
@ -405,13 +420,10 @@ async def find_time_slots_when_everyone_is_free(
"""
Provides time slots when everyone is free within a given date range and time boundaries.
"""
credentials = Credentials(
context.authorization.token if context.authorization and context.authorization.token else ""
)
# Build google api services
oauth_service = build("oauth2", "v2", credentials=credentials)
calendar_service = build("calendar", "v3", credentials=credentials)
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 []

View file

@ -3,8 +3,6 @@ from typing import Annotated
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Google
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from arcade_google.constants import DEFAULT_SEARCH_CONTACTS_LIMIT
from arcade_google.utils import build_people_service, search_contacts
@ -32,9 +30,7 @@ async def search_contacts_by_email(
"""
Search the user's contacts in Google Contacts by email address.
"""
service = build_people_service(
context.authorization.token if context.authorization and context.authorization.token else ""
)
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)
@ -54,9 +50,7 @@ async def search_contacts_by_name(
"""
Search the user's contacts in Google Contacts by name.
"""
service = build_people_service(
context.authorization.token if context.authorization and context.authorization.token else ""
)
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)
@ -81,15 +75,7 @@ async def create_contact(
```
"""
# Build the People API service
service = build(
"people",
"v1",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
service = build_people_service(context.get_auth_token_or_empty())
# Construct the person payload with the specified names
name_body = {"givenName": given_name}

View file

@ -828,6 +828,24 @@ def merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[da
# Calendar utils
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]

View file

@ -15,6 +15,7 @@ from arcade_google.models import EventVisibility, SendUpdatesOptions
from arcade_google.tools.calendar import (
create_event,
delete_event,
list_calendars,
list_events,
update_event,
)
@ -73,6 +74,19 @@ def calendar_eval_suite() -> EvalSuite:
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",
@ -139,7 +153,7 @@ def calendar_eval_suite() -> EvalSuite:
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 the meeting my meeting tomorrow at 3pm to 4pm. Let everyone know."
"Change my meeting tomorrow at 3pm to 4pm. Let everyone know."
),
expected_tool_calls=[
ExpectedToolCall(

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "arcade_google"
version = "1.1.1"
version = "1.1.2"
description = "Arcade.dev LLM tools for Google Workspace"
authors = ["Arcade <dev@arcade.dev>"]

View file

@ -12,6 +12,7 @@ from arcade_google.tools.calendar import (
create_event,
delete_event,
find_time_slots_when_everyone_is_free,
list_calendars,
list_events,
update_event,
)
@ -24,7 +25,92 @@ def mock_context():
@pytest.mark.asyncio
@patch("arcade_google.tools.calendar.build")
@patch("arcade_google.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.tools.calendar.build_calendar_service")
async def test_create_event(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
@ -52,7 +138,7 @@ async def test_create_event(mock_build, mock_context):
@pytest.mark.asyncio
@patch("arcade_google.tools.calendar.build")
@patch("arcade_google.tools.calendar.build_calendar_service")
async def test_list_events(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
@ -117,7 +203,7 @@ async def test_list_events(mock_build, mock_context):
@pytest.mark.asyncio
@patch("arcade_google.tools.calendar.build")
@patch("arcade_google.tools.calendar.build_calendar_service")
async def test_update_event(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
@ -145,7 +231,7 @@ async def test_update_event(mock_build, mock_context):
@pytest.mark.asyncio
@patch("arcade_google.tools.calendar.build")
@patch("arcade_google.tools.calendar.build_calendar_service")
async def test_delete_event(mock_build, mock_context):
mock_service = MagicMock()
mock_build.return_value = mock_service
@ -164,15 +250,19 @@ async def test_delete_event(mock_build, mock_context):
@pytest.mark.asyncio
@patch("arcade_google.utils.get_now")
@patch("arcade_google.tools.calendar.build")
async def test_find_free_slots_happiest_path_single_user(mock_build, mock_get_now, mock_context):
@patch("arcade_google.tools.calendar.build_oauth_service")
@patch("arcade_google.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.side_effect = [oauth_service, calendar_service]
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",
@ -226,9 +316,10 @@ async def test_find_free_slots_happiest_path_single_user(mock_build, mock_get_no
@pytest.mark.asyncio
@patch("arcade_google.utils.get_now")
@patch("arcade_google.tools.calendar.build")
@patch("arcade_google.tools.calendar.build_oauth_service")
@patch("arcade_google.tools.calendar.build_calendar_service")
async def test_find_free_slots_happiest_path_single_user_with_busy_times(
mock_build, mock_get_now, mock_context
mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context
):
calendar_service = MagicMock()
oauth_service = MagicMock()
@ -236,7 +327,9 @@ async def test_find_free_slots_happiest_path_single_user_with_busy_times(
mock_get_now.return_value = datetime(
2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles")
)
mock_build.side_effect = [oauth_service, calendar_service]
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",
@ -321,9 +414,10 @@ async def test_find_free_slots_happiest_path_single_user_with_busy_times(
@pytest.mark.asyncio
@patch("arcade_google.utils.get_now")
@patch("arcade_google.tools.calendar.build")
@patch("arcade_google.tools.calendar.build_oauth_service")
@patch("arcade_google.tools.calendar.build_calendar_service")
async def test_find_free_slots_happiest_path_multiple_users_with_busy_times(
mock_build, mock_get_now, mock_context
mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context
):
calendar_service = MagicMock()
oauth_service = MagicMock()
@ -331,7 +425,9 @@ async def test_find_free_slots_happiest_path_multiple_users_with_busy_times(
mock_get_now.return_value = datetime(
2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles")
)
mock_build.side_effect = [oauth_service, calendar_service]
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",
@ -428,9 +524,10 @@ async def test_find_free_slots_happiest_path_multiple_users_with_busy_times(
@pytest.mark.asyncio
@patch("arcade_google.utils.get_now")
@patch("arcade_google.tools.calendar.build")
@patch("arcade_google.tools.calendar.build_oauth_service")
@patch("arcade_google.tools.calendar.build_calendar_service")
async def test_find_free_slots_with_google_calendar_error_not_found(
mock_build, mock_get_now, mock_context
mock_build_calendar_service, mock_build_oauth_service, mock_get_now, mock_context
):
calendar_service = MagicMock()
oauth_service = MagicMock()
@ -438,7 +535,8 @@ async def test_find_free_slots_with_google_calendar_error_not_found(
mock_get_now.return_value = datetime(
2025, 3, 10, 9, 25, 0, tzinfo=ZoneInfo("America/Los_Angeles")
)
mock_build.side_effect = [oauth_service, calendar_service]
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",

View file

@ -28,7 +28,9 @@ async def test_create_contact_success(mock_context):
service_mock = MagicMock()
service_mock.people.return_value = people_mock
with patch("arcade_google.tools.contacts.build", return_value=service_mock) as mock_build:
with patch(
"arcade_google.tools.contacts.build_people_service", return_value=service_mock
) as mock_build:
result = await create_contact(
mock_context,
given_name="Alice",
@ -63,7 +65,7 @@ async def test_create_contact_success_without_optional(mock_context):
service_mock = MagicMock()
service_mock.people.return_value = people_mock
with patch("arcade_google.tools.contacts.build", return_value=service_mock):
with patch("arcade_google.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
@ -88,7 +90,7 @@ async def test_create_contact_error(mock_context):
service_mock.people.return_value = people_mock
with (
patch("arcade_google.tools.contacts.build", return_value=service_mock),
patch("arcade_google.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)