Mateo/google calendar list (#367)
Adds "ListCalendars" to the calendar toolkit
This commit is contained in:
parent
2dc70a7814
commit
eb6b7f3987
7 changed files with 211 additions and 81 deletions
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue