### Overview Major restructuring from monolithic `arcade-ai` package to modular library architecture with standardized uv-based dependency management.  ### New Package Structure - **`arcade-tdk`** - Lightweight toolkit development kit (core decorators, auth) - **`arcade-core`** - Core execution engine and catalog functionality - **`arcade-serve`** - FastAPI/MCP server components - **`arcade-ai`** - Meta package that includes CLI functionality. Optionally include evals via the `evals` extra. Optionally include all packages via the `all` extra. ### Key Benefits - **Lighter Dependencies**: Toolkits now depend only on `arcade-tdk` (~2 deps) vs full `arcade-ai` (~30+ deps) - **Faster Builds**: uv provides 10-100x faster dependency resolution and installation - **Better Modularity**: Clear separation of concerns, consumers import only what they need - **Standard Tooling**: Eliminates custom poetry scripts, uses standard Python packaging ### Migration Impact - All 20 toolkits converted from poetry → uv with `arcade-tdk` dependencies plus `arcade-ai[evals]` and `arcade-serve` dev dependencies. When developing locally, devs should install toolkits via `make install-local`. - Modern Python 3.10+ type hints throughout - Standardized build system with hatchling backend - Enhanced Makefile with robust toolkit management commands - Removed `arcade dev` CLI command - Reduce the number of files created by `arcade new` and add an option to not generate a tests and evals folder. This foundation enables faster development cycles and cleaner dependency chains for the growing toolkit ecosystem. ### Todo After this PR is merged - [ ] Post-merge workflow(s) (release & publish containers, etc) - [ ] Release order plan. @EricGustin suggests releasing in the following order: 1. `arcade-core` version 0.1.0 2. `arcade-serve` version 0.1.0 and `arcade-tdk` version 0.1.0 3. `arcade-ai` version 2.0.0 4. Patch release for all toolkits (all changes in toolkits are internal refactors) - [ ] [Update docs](https://github.com/ArcadeAI/docs/pull/318) --------- Co-authored-by: Eric Gustin <eric@arcade.dev> Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
582 lines
20 KiB
Python
582 lines
20 KiB
Python
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.models import EventVisibility, SendUpdatesOptions
|
|
from arcade_google.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.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
|
|
|
|
# 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.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.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.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.utils.get_now")
|
|
@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_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.utils.get_now")
|
|
@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_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.utils.get_now")
|
|
@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_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.utils.get_now")
|
|
@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_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",
|
|
)
|