diff --git a/toolkits/google/arcade_google/tools/calendar.py b/toolkits/google/arcade_google/tools/calendar.py index a5456996..ee0bd293 100644 --- a/toolkits/google/arcade_google/tools/calendar.py +++ b/toolkits/google/arcade_google/tools/calendar.py @@ -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 [] diff --git a/toolkits/google/arcade_google/tools/contacts.py b/toolkits/google/arcade_google/tools/contacts.py index 6ac6134e..96003cf5 100644 --- a/toolkits/google/arcade_google/tools/contacts.py +++ b/toolkits/google/arcade_google/tools/contacts.py @@ -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} diff --git a/toolkits/google/arcade_google/utils.py b/toolkits/google/arcade_google/utils.py index 8c0bd55a..06a3f098 100644 --- a/toolkits/google/arcade_google/utils.py +++ b/toolkits/google/arcade_google/utils.py @@ -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] diff --git a/toolkits/google/evals/eval_google_calendar.py b/toolkits/google/evals/eval_google_calendar.py index 669c24e9..f06d610b 100644 --- a/toolkits/google/evals/eval_google_calendar.py +++ b/toolkits/google/evals/eval_google_calendar.py @@ -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( diff --git a/toolkits/google/pyproject.toml b/toolkits/google/pyproject.toml index c79e42f6..7abbdb5a 100644 --- a/toolkits/google/pyproject.toml +++ b/toolkits/google/pyproject.toml @@ -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 "] diff --git a/toolkits/google/tests/test_calendar.py b/toolkits/google/tests/test_calendar.py index 296c3cd8..e5414350 100644 --- a/toolkits/google/tests/test_calendar.py +++ b/toolkits/google/tests/test_calendar.py @@ -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", diff --git a/toolkits/google/tests/test_contacts.py b/toolkits/google/tests/test_contacts.py index 6f62433b..9ec19b32 100644 --- a/toolkits/google/tests/test_contacts.py +++ b/toolkits/google/tests/test_contacts.py @@ -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)