arcade-google 0.0.13__py3-none-any.whl

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.
File without changes
@@ -0,0 +1 @@
1
+ __all__ = ["gmail", "calendar", "drive", "docs"]
@@ -0,0 +1,307 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Annotated
3
+
4
+ from google.oauth2.credentials import Credentials
5
+ from googleapiclient.discovery import build
6
+ from googleapiclient.errors import HttpError
7
+
8
+ from arcade.core.errors import RetryableToolError
9
+ from arcade.core.schema import ToolContext
10
+ from arcade.sdk import tool
11
+ from arcade.sdk.auth import Google
12
+ from arcade_google.tools.models import EventVisibility, SendUpdatesOptions
13
+ from arcade_google.tools.utils import parse_datetime
14
+
15
+
16
+ @tool(
17
+ requires_auth=Google(
18
+ scopes=[
19
+ "https://www.googleapis.com/auth/calendar.readonly",
20
+ "https://www.googleapis.com/auth/calendar.events",
21
+ ],
22
+ )
23
+ )
24
+ async def create_event(
25
+ context: ToolContext,
26
+ summary: Annotated[str, "The title of the event"],
27
+ start_datetime: Annotated[
28
+ str,
29
+ "The datetime when the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.",
30
+ ],
31
+ end_datetime: Annotated[
32
+ str,
33
+ "The datetime when the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
34
+ ],
35
+ calendar_id: Annotated[
36
+ str, "The ID of the calendar to create the event in, usually 'primary'."
37
+ ] = "primary",
38
+ description: Annotated[str | None, "The description of the event"] = None,
39
+ location: Annotated[str | None, "The location of the event"] = None,
40
+ visibility: Annotated[EventVisibility, "The visibility of the event"] = EventVisibility.DEFAULT,
41
+ attendee_emails: Annotated[
42
+ list[str] | None,
43
+ "The list of attendee emails. Must be valid email addresses e.g., username@domain.com.",
44
+ ] = None,
45
+ ) -> Annotated[dict, "A dictionary containing the created event details"]:
46
+ """Create a new event/meeting/sync/meetup in the specified calendar."""
47
+
48
+ service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
49
+
50
+ # Get the calendar's time zone
51
+ calendar = service.calendars().get(calendarId=calendar_id).execute()
52
+ time_zone = calendar["timeZone"]
53
+
54
+ # Parse datetime strings
55
+ start_dt = parse_datetime(start_datetime, time_zone)
56
+ end_dt = parse_datetime(end_datetime, time_zone)
57
+
58
+ event = {
59
+ "summary": summary,
60
+ "description": description,
61
+ "location": location,
62
+ "start": {"dateTime": start_dt.isoformat(), "timeZone": time_zone},
63
+ "end": {"dateTime": end_dt.isoformat(), "timeZone": time_zone},
64
+ "visibility": visibility.value,
65
+ }
66
+
67
+ if attendee_emails:
68
+ event["attendees"] = [{"email": email} for email in attendee_emails]
69
+
70
+ created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
71
+ return {"event": created_event}
72
+
73
+
74
+ @tool(
75
+ requires_auth=Google(
76
+ scopes=[
77
+ "https://www.googleapis.com/auth/calendar.readonly",
78
+ "https://www.googleapis.com/auth/calendar.events",
79
+ ],
80
+ )
81
+ )
82
+ async def list_events(
83
+ context: ToolContext,
84
+ min_end_datetime: Annotated[
85
+ str,
86
+ "Filter by events that end on or after this datetime in ISO 8601 format, e.g., '2024-09-15T09:00:00'.",
87
+ ],
88
+ max_start_datetime: Annotated[
89
+ str,
90
+ "Filter by events that start before this datetime in ISO 8601 format, e.g., '2024-09-16T17:00:00'.",
91
+ ],
92
+ calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary",
93
+ max_results: Annotated[int, "The maximum number of events to return"] = 10,
94
+ ) -> Annotated[dict, "A dictionary containing the list of events"]:
95
+ """
96
+ List events from the specified calendar within the given datetime range.
97
+
98
+ min_end_datetime serves as the lower bound (exclusive) for an event's end time.
99
+ max_start_datetime serves as the upper bound (exclusive) for an event's start time.
100
+
101
+ For example:
102
+ If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime is set to 2024-09-16T17:00:00,
103
+ the function will return events that:
104
+ 1. End after 09:00 on September 15, 2024 (exclusive)
105
+ 2. Start before 17:00 on September 16, 2024 (exclusive)
106
+ This means an event starting at 08:00 on September 15 and ending at 10:00 on September 15 would be included,
107
+ but an event starting at 17:00 on September 16 would not be included.
108
+ """
109
+ service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
110
+
111
+ # Get the calendar's time zone
112
+ calendar = service.calendars().get(calendarId=calendar_id).execute()
113
+ time_zone = calendar["timeZone"]
114
+
115
+ # Parse datetime strings
116
+ min_end_dt = parse_datetime(min_end_datetime, time_zone)
117
+ max_start_dt = parse_datetime(max_start_datetime, time_zone)
118
+
119
+ if min_end_dt > max_start_dt:
120
+ min_end_dt, max_start_dt = max_start_dt, min_end_dt
121
+
122
+ events_result = (
123
+ service.events()
124
+ .list(
125
+ calendarId=calendar_id,
126
+ timeMin=min_end_dt.isoformat(),
127
+ timeMax=max_start_dt.isoformat(),
128
+ maxResults=max_results,
129
+ singleEvents=True,
130
+ orderBy="startTime",
131
+ )
132
+ .execute()
133
+ )
134
+
135
+ items_keys = [
136
+ "attachments",
137
+ "attendees",
138
+ "creator",
139
+ "description",
140
+ "end",
141
+ "eventType",
142
+ "htmlLink",
143
+ "id",
144
+ "location",
145
+ "organizer",
146
+ "start",
147
+ "summary",
148
+ "visibility",
149
+ ]
150
+
151
+ events = [
152
+ {key: event[key] for key in items_keys if key in event}
153
+ for event in events_result.get("items", [])
154
+ ]
155
+
156
+ return {"events_count": len(events), "events": events}
157
+
158
+
159
+ @tool(
160
+ requires_auth=Google(
161
+ scopes=["https://www.googleapis.com/auth/calendar"],
162
+ )
163
+ )
164
+ async def update_event(
165
+ context: ToolContext,
166
+ event_id: Annotated[str, "The ID of the event to update"],
167
+ updated_start_datetime: Annotated[
168
+ str | None,
169
+ "The updated datetime that the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.",
170
+ ] = None,
171
+ updated_end_datetime: Annotated[
172
+ str | None,
173
+ "The updated datetime that the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
174
+ ] = None,
175
+ updated_calendar_id: Annotated[
176
+ str | None, "The updated ID of the calendar containing the event."
177
+ ] = None,
178
+ updated_summary: Annotated[str | None, "The updated title of the event"] = None,
179
+ updated_description: Annotated[str | None, "The updated description of the event"] = None,
180
+ updated_location: Annotated[str | None, "The updated location of the event"] = None,
181
+ updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None,
182
+ attendee_emails_to_add: Annotated[
183
+ list[str] | None,
184
+ "The list of attendee emails to add. Must be valid email addresses e.g., username@domain.com.",
185
+ ] = None,
186
+ attendee_emails_to_remove: Annotated[
187
+ list[str] | None,
188
+ "The list of attendee emails to remove. Must be valid email addresses e.g., username@domain.com.",
189
+ ] = None,
190
+ send_updates: Annotated[
191
+ SendUpdatesOptions, "Should attendees be notified of the update? (none, all, external_only)"
192
+ ] = SendUpdatesOptions.ALL,
193
+ ) -> Annotated[
194
+ str,
195
+ "A string containing the updated event details, including the event ID, update timestamp, and a link to view the updated event.",
196
+ ]:
197
+ """
198
+ Update an existing event in the specified calendar with the provided details.
199
+ Only the provided fields will be updated; others will remain unchanged.
200
+
201
+ `updated_start_datetime` and `updated_end_datetime` are independent and can be provided separately.
202
+ """
203
+ service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
204
+
205
+ calendar = service.calendars().get(calendarId="primary").execute()
206
+ time_zone = calendar["timeZone"]
207
+
208
+ try:
209
+ event = service.events().get(calendarId="primary", eventId=event_id).execute()
210
+ except HttpError:
211
+ valid_events_with_id = (
212
+ service.events()
213
+ .list(
214
+ calendarId="primary",
215
+ timeMin=(datetime.now() - timedelta(days=2)).isoformat(),
216
+ timeMax=(datetime.now() + timedelta(days=365)).isoformat(),
217
+ maxResults=50,
218
+ singleEvents=True,
219
+ orderBy="startTime",
220
+ )
221
+ .execute()
222
+ )
223
+ raise RetryableToolError(
224
+ f"Event with ID {event_id} not found.",
225
+ additional_prompt_content=f"Here is a list of valid events. The event_id parameter must match one of these: {valid_events_with_id}",
226
+ retry_after_ms=1000,
227
+ developer_message=f"Event with ID {event_id} not found. Please try again with a valid event ID.",
228
+ )
229
+
230
+ update_fields = {
231
+ "start": {"dateTime": updated_start_datetime.isoformat(), "timeZone": time_zone}
232
+ if updated_start_datetime
233
+ else None,
234
+ "end": {"dateTime": updated_end_datetime.isoformat(), "timeZone": time_zone}
235
+ if updated_end_datetime
236
+ else None,
237
+ "calendarId": updated_calendar_id,
238
+ "sendUpdates": send_updates.value if send_updates else None,
239
+ "summary": updated_summary,
240
+ "description": updated_description,
241
+ "location": updated_location,
242
+ "visibility": updated_visibility.value if updated_visibility else None,
243
+ }
244
+
245
+ event.update({k: v for k, v in update_fields.items() if v is not None})
246
+
247
+ if attendee_emails_to_remove:
248
+ event["attendees"] = [
249
+ attendee
250
+ for attendee in event.get("attendees", [])
251
+ if attendee.get("email", "").lower()
252
+ not in [email.lower() for email in attendee_emails_to_remove]
253
+ ]
254
+
255
+ if attendee_emails_to_add:
256
+ existing_emails = {
257
+ attendee.get("email", "").lower() for attendee in event.get("attendees", [])
258
+ }
259
+ new_attendees = [
260
+ {"email": email}
261
+ for email in attendee_emails_to_add
262
+ if email.lower() not in existing_emails
263
+ ]
264
+ event["attendees"] = event.get("attendees", []) + new_attendees
265
+
266
+ updated_event = (
267
+ service.events()
268
+ .update(
269
+ calendarId="primary",
270
+ eventId=event_id,
271
+ sendUpdates=send_updates.value,
272
+ body=event,
273
+ )
274
+ .execute()
275
+ )
276
+ return f"Event with ID {event_id} successfully updated at {updated_event['updated']}. View updated event at {updated_event['htmlLink']}"
277
+
278
+
279
+ @tool(
280
+ requires_auth=Google(
281
+ scopes=["https://www.googleapis.com/auth/calendar.events"],
282
+ )
283
+ )
284
+ async def delete_event(
285
+ context: ToolContext,
286
+ event_id: Annotated[str, "The ID of the event to delete"],
287
+ calendar_id: Annotated[str, "The ID of the calendar containing the event"] = "primary",
288
+ send_updates: Annotated[
289
+ SendUpdatesOptions, "Specifies which attendees to notify about the deletion"
290
+ ] = SendUpdatesOptions.ALL,
291
+ ) -> Annotated[str, "A string containing the deletion confirmation message"]:
292
+ """Delete an event from Google Calendar."""
293
+ service = build("calendar", "v3", credentials=Credentials(context.authorization.token))
294
+
295
+ service.events().delete(
296
+ calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value
297
+ ).execute()
298
+
299
+ notification_message = ""
300
+ if send_updates == SendUpdatesOptions.ALL:
301
+ notification_message = "Notifications were sent to all attendees."
302
+ elif send_updates == SendUpdatesOptions.EXTERNAL_ONLY:
303
+ notification_message = "Notifications were sent to external attendees only."
304
+ elif send_updates == SendUpdatesOptions.NONE:
305
+ notification_message = "No notifications were sent to attendees."
306
+
307
+ return f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. {notification_message}"
@@ -0,0 +1,151 @@
1
+ from typing import Annotated
2
+
3
+ from arcade.core.schema import ToolContext
4
+ from arcade.sdk import tool
5
+ from arcade.sdk.auth import Google
6
+ from arcade_google.tools.utils import build_docs_service
7
+
8
+
9
+ # Uses https://developers.google.com/docs/api/reference/rest/v1/documents/get
10
+ # Example `arcade chat` query: `get document with ID 1234567890`
11
+ # Note: Document IDs are returned in the response of the Google Drive's `list_documents` tool
12
+ @tool(
13
+ requires_auth=Google(
14
+ scopes=[
15
+ "https://www.googleapis.com/auth/documents.readonly",
16
+ ],
17
+ )
18
+ )
19
+ async def get_document_by_id(
20
+ context: ToolContext,
21
+ document_id: Annotated[str, "The ID of the document to retrieve."],
22
+ ) -> Annotated[dict, "The document contents as a dictionary"]:
23
+ """
24
+ Get the latest version of the specified Google Docs document.
25
+ """
26
+ service = build_docs_service(context.authorization.token)
27
+
28
+ # Execute the documents().get() method. Returns a Document object
29
+ # https://developers.google.com/docs/api/reference/rest/v1/documents#Document
30
+ request = service.documents().get(documentId=document_id)
31
+ response = request.execute()
32
+ return response
33
+
34
+
35
+ # Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
36
+ # Example `arcade chat` query: `insert "The END" at the end of document with ID 1234567890`
37
+ @tool(
38
+ requires_auth=Google(
39
+ scopes=[
40
+ "https://www.googleapis.com/auth/documents",
41
+ ],
42
+ )
43
+ )
44
+ async def insert_text_at_end_of_document(
45
+ context: ToolContext,
46
+ document_id: Annotated[str, "The ID of the document to update."],
47
+ text_content: Annotated[str, "The text content to insert into the document"],
48
+ ) -> Annotated[dict, "The response from the batchUpdate API as a dict."]:
49
+ """
50
+ Updates an existing Google Docs document using the batchUpdate API endpoint.
51
+ """
52
+ document = await get_document_by_id(context, document_id)
53
+
54
+ end_index = document["body"]["content"][-1]["endIndex"]
55
+
56
+ service = build_docs_service(context.authorization.token)
57
+
58
+ requests = [
59
+ {
60
+ "insertText": {
61
+ "location": {
62
+ "index": int(end_index) - 1,
63
+ },
64
+ "text": text_content,
65
+ }
66
+ }
67
+ ]
68
+
69
+ # Execute the documents().batchUpdate() method
70
+ response = (
71
+ service.documents()
72
+ .batchUpdate(documentId=document_id, body={"requests": requests})
73
+ .execute()
74
+ )
75
+
76
+ return response
77
+
78
+
79
+ # Uses https://developers.google.com/docs/api/reference/rest/v1/documents/create
80
+ # Example `arcade chat` query: `create blank document with title "My New Document"`
81
+ @tool(
82
+ requires_auth=Google(
83
+ scopes=[
84
+ "https://www.googleapis.com/auth/documents",
85
+ ],
86
+ )
87
+ )
88
+ async def create_blank_document(
89
+ context: ToolContext, title: Annotated[str, "The title of the blank document to create"]
90
+ ) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]:
91
+ """
92
+ Create a blank Google Docs document with the specified title.
93
+ """
94
+ service = build_docs_service(context.authorization.token)
95
+
96
+ body = {"title": title}
97
+
98
+ # Execute the documents().create() method. Returns a Document object https://developers.google.com/docs/api/reference/rest/v1/documents#Document
99
+ request = service.documents().create(body=body)
100
+ response = request.execute()
101
+
102
+ return {
103
+ "title": response["title"],
104
+ "documentId": response["documentId"],
105
+ "documentUrl": f"https://docs.google.com/document/d/{response['documentId']}/edit",
106
+ }
107
+
108
+
109
+ # Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
110
+ # Example `arcade chat` query: `create document with title "My New Document" and text content "Hello, World!"`
111
+ @tool(
112
+ requires_auth=Google(
113
+ scopes=[
114
+ "https://www.googleapis.com/auth/documents",
115
+ ],
116
+ )
117
+ )
118
+ async def create_document_from_text(
119
+ context: ToolContext,
120
+ title: Annotated[str, "The title of the document to create"],
121
+ text_content: Annotated[str, "The text content to insert into the document"],
122
+ ) -> Annotated[dict, "The created document's title, documentId, and documentUrl in a dictionary"]:
123
+ """
124
+ Create a Google Docs document with the specified title and text content.
125
+ """
126
+ # First, create a blank document
127
+ document = await create_blank_document(context, title)
128
+
129
+ service = build_docs_service(context.authorization.token)
130
+
131
+ requests = [
132
+ {
133
+ "insertText": {
134
+ "location": {
135
+ "index": 1,
136
+ },
137
+ "text": text_content,
138
+ }
139
+ }
140
+ ]
141
+
142
+ # Execute the batchUpdate method to insert text
143
+ service.documents().batchUpdate(
144
+ documentId=document["documentId"], body={"requests": requests}
145
+ ).execute()
146
+
147
+ return {
148
+ "title": document["title"],
149
+ "documentId": document["documentId"],
150
+ "documentUrl": f"https://docs.google.com/document/d/{document['documentId']}/edit",
151
+ }
@@ -0,0 +1,80 @@
1
+ from typing import Annotated, Optional
2
+
3
+ from arcade.core.schema import ToolContext
4
+ from arcade.sdk import tool
5
+ from arcade.sdk.auth import Google
6
+ from arcade_google.tools.utils import build_drive_service, remove_none_values
7
+
8
+ from .models import Corpora, OrderBy
9
+
10
+
11
+ # Implements: https://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.files.html#list
12
+ # Example `arcade chat` query: `list my 5 most recently modified documents`
13
+ # TODO: Support query with natural language. Currently, the tool expects a fully formed query string as input with the syntax defined here: https://developers.google.com/drive/api/guides/search-files
14
+ @tool(
15
+ requires_auth=Google(
16
+ scopes=["https://www.googleapis.com/auth/drive.readonly"],
17
+ )
18
+ )
19
+ async def list_documents(
20
+ context: ToolContext,
21
+ corpora: Annotated[Optional[Corpora], "The source of files to list"] = Corpora.USER,
22
+ title_keywords: Annotated[
23
+ Optional[list[str]], "Keywords or phrases that must be in the document title"
24
+ ] = None,
25
+ order_by: Annotated[
26
+ Optional[OrderBy],
27
+ "Sort order. Defaults to listing the most recently modified documents first",
28
+ ] = OrderBy.MODIFIED_TIME_DESC,
29
+ supports_all_drives: Annotated[
30
+ Optional[bool],
31
+ "Whether the requesting application supports both My Drives and shared drives",
32
+ ] = False,
33
+ limit: Annotated[Optional[int], "The number of documents to list"] = 50,
34
+ ) -> Annotated[
35
+ dict,
36
+ "A dictionary containing 'documents_count' (number of documents returned) and 'documents' (a list of document details including 'kind', 'mimeType', 'id', and 'name' for each document)",
37
+ ]:
38
+ """
39
+ List documents in the user's Google Drive. Excludes documents that are in the trash.
40
+ """
41
+ page_size = min(10, limit)
42
+ page_token = None # The page token is used for continuing a previous request on the next page
43
+ files = []
44
+
45
+ service = build_drive_service(context.authorization.token)
46
+
47
+ query = "mimeType = 'application/vnd.google-apps.document' and trashed = false"
48
+ if title_keywords:
49
+ # Escape single quotes in title_keywords
50
+ title_keywords = [keyword.replace("'", "\\'") for keyword in title_keywords]
51
+ # Only support logically ANDed keywords in query for now
52
+ keyword_queries = [f"name contains '{keyword}'" for keyword in title_keywords]
53
+ query += " and " + " and ".join(keyword_queries)
54
+
55
+ # Prepare the request parameters
56
+ params = {
57
+ "q": query,
58
+ "pageSize": page_size,
59
+ "orderBy": order_by.value,
60
+ "corpora": corpora.value,
61
+ "supportsAllDrives": supports_all_drives,
62
+ }
63
+ params = remove_none_values(params)
64
+
65
+ # Paginate through the results until the limit is reached
66
+ while len(files) < limit:
67
+ if page_token:
68
+ params["pageToken"] = page_token
69
+ else:
70
+ params.pop("pageToken", None)
71
+
72
+ results = service.files().list(**params).execute()
73
+ batch = results.get("files", [])
74
+ files.extend(batch[: limit - len(files)])
75
+
76
+ page_token = results.get("nextPageToken")
77
+ if not page_token or len(batch) < page_size:
78
+ break
79
+
80
+ return {"documents_count": len(files), "documents": files}