arcade-google 0.1.6__py3-none-any.whl → 1.2.4__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.
arcade_google/utils.py ADDED
@@ -0,0 +1,1564 @@
1
+ import logging
2
+ import re
3
+ from base64 import urlsafe_b64decode, urlsafe_b64encode
4
+ from datetime import date, datetime, time, timedelta, timezone
5
+ from email.message import EmailMessage
6
+ from email.mime.text import MIMEText
7
+ from enum import Enum
8
+ from typing import Any, cast
9
+ from zoneinfo import ZoneInfo
10
+
11
+ from arcade_tdk import ToolContext
12
+ from arcade_tdk.errors import RetryableToolError, ToolExecutionError
13
+ from bs4 import BeautifulSoup
14
+ from google.oauth2.credentials import Credentials
15
+ from googleapiclient.discovery import Resource, build
16
+
17
+ from arcade_google.constants import (
18
+ DEFAULT_SEARCH_CONTACTS_LIMIT,
19
+ DEFAULT_SHEET_COLUMN_COUNT,
20
+ DEFAULT_SHEET_ROW_COUNT,
21
+ )
22
+ from arcade_google.exceptions import GmailToolError, GoogleServiceError
23
+ from arcade_google.models import (
24
+ CellData,
25
+ CellExtendedValue,
26
+ CellFormat,
27
+ CellValue,
28
+ Corpora,
29
+ Day,
30
+ GmailAction,
31
+ GmailReplyToWhom,
32
+ GridData,
33
+ GridProperties,
34
+ NumberFormat,
35
+ NumberFormatType,
36
+ OrderBy,
37
+ RowData,
38
+ Sheet,
39
+ SheetDataInput,
40
+ SheetProperties,
41
+ TimeSlot,
42
+ )
43
+
44
+ ## Set up basic configuration for logging to the console with DEBUG level and a specific format.
45
+ logging.basicConfig(
46
+ level=logging.DEBUG,
47
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
48
+ )
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ def parse_datetime(datetime_str: str, time_zone: str) -> datetime:
54
+ """
55
+ Parse a datetime string in ISO 8601 format and ensure it is timezone-aware.
56
+
57
+ Args:
58
+ datetime_str (str): The datetime string to parse. Expected format: 'YYYY-MM-DDTHH:MM:SS'.
59
+ time_zone (str): The timezone to apply if the datetime string is naive.
60
+
61
+ Returns:
62
+ datetime: A timezone-aware datetime object.
63
+
64
+ Raises:
65
+ ValueError: If the datetime string is not in the correct format.
66
+ """
67
+ datetime_str = datetime_str.upper().strip().rstrip("Z")
68
+ try:
69
+ dt = datetime.fromisoformat(datetime_str)
70
+ if dt.tzinfo is None:
71
+ dt = dt.replace(tzinfo=ZoneInfo(time_zone))
72
+ except ValueError as e:
73
+ raise ValueError(
74
+ f"Invalid datetime format: '{datetime_str}'. "
75
+ "Expected ISO 8601 format, e.g., '2024-12-31T15:30:00'."
76
+ ) from e
77
+ return dt
78
+
79
+
80
+ class DateRange(Enum):
81
+ TODAY = "today"
82
+ YESTERDAY = "yesterday"
83
+ LAST_7_DAYS = "last_7_days"
84
+ LAST_30_DAYS = "last_30_days"
85
+ THIS_MONTH = "this_month"
86
+ LAST_MONTH = "last_month"
87
+ THIS_YEAR = "this_year"
88
+
89
+ def to_date_query(self) -> str:
90
+ today = datetime.now()
91
+ result = "after:"
92
+ comparison_date = today
93
+
94
+ if self == DateRange.YESTERDAY:
95
+ comparison_date = today - timedelta(days=1)
96
+ elif self == DateRange.LAST_7_DAYS:
97
+ comparison_date = today - timedelta(days=7)
98
+ elif self == DateRange.LAST_30_DAYS:
99
+ comparison_date = today - timedelta(days=30)
100
+ elif self == DateRange.THIS_MONTH:
101
+ comparison_date = today.replace(day=1)
102
+ elif self == DateRange.LAST_MONTH:
103
+ comparison_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
104
+ elif self == DateRange.THIS_YEAR:
105
+ comparison_date = today.replace(month=1, day=1)
106
+ elif self == DateRange.LAST_MONTH:
107
+ comparison_date = (today.replace(month=1, day=1) - timedelta(days=1)).replace(
108
+ month=1, day=1
109
+ )
110
+
111
+ return result + comparison_date.strftime("%Y/%m/%d")
112
+
113
+
114
+ def build_email_message(
115
+ recipient: str,
116
+ subject: str,
117
+ body: str,
118
+ cc: list[str] | None = None,
119
+ bcc: list[str] | None = None,
120
+ replying_to: dict[str, Any] | None = None,
121
+ action: GmailAction = GmailAction.SEND,
122
+ ) -> dict[str, Any]:
123
+ if replying_to:
124
+ body = build_reply_body(body, replying_to)
125
+
126
+ message: EmailMessage | MIMEText
127
+
128
+ if action == GmailAction.SEND:
129
+ message = EmailMessage()
130
+ message.set_content(body)
131
+ elif action == GmailAction.DRAFT:
132
+ message = MIMEText(body)
133
+
134
+ message["To"] = recipient
135
+ message["Subject"] = subject
136
+
137
+ if cc:
138
+ message["Cc"] = ",".join(cc)
139
+ if bcc:
140
+ message["Bcc"] = ",".join(bcc)
141
+ if replying_to:
142
+ message["In-Reply-To"] = replying_to["header_message_id"]
143
+ message["References"] = f"{replying_to['header_message_id']}, {replying_to['references']}"
144
+
145
+ encoded_message = urlsafe_b64encode(message.as_bytes()).decode()
146
+
147
+ data = {"raw": encoded_message}
148
+
149
+ if replying_to:
150
+ data["threadId"] = replying_to["thread_id"]
151
+
152
+ return data
153
+
154
+
155
+ def build_reply_body(body: str, replying_to: dict[str, Any]) -> str:
156
+ attribution = f"On {replying_to['date']}, {replying_to['from']} wrote:"
157
+ lines = replying_to["plain_text_body"].split("\n")
158
+ quoted_plain = "\n".join([f"> {line}" for line in lines])
159
+ return f"{body}\n\n{attribution}\n\n{quoted_plain}"
160
+
161
+
162
+ def build_reply_recipients(
163
+ replying_to: dict[str, Any], current_user_email_address: str, reply_to_whom: GmailReplyToWhom
164
+ ) -> str:
165
+ if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER:
166
+ recipients = [replying_to["from"]]
167
+ elif reply_to_whom == GmailReplyToWhom.EVERY_RECIPIENT:
168
+ recipients = [replying_to["from"], *replying_to["to"].split(",")]
169
+ else:
170
+ raise ValueError(f"Unsupported reply_to_whom value: {reply_to_whom}")
171
+
172
+ recipients = [
173
+ email_address.strip()
174
+ for email_address in recipients
175
+ if email_address.strip().lower() != current_user_email_address.lower().strip()
176
+ ]
177
+
178
+ return ", ".join(recipients)
179
+
180
+
181
+ def parse_plain_text_email(email_data: dict[str, Any]) -> dict[str, Any]:
182
+ """
183
+ Parse email data and extract relevant information.
184
+ Only returns the plain text body.
185
+
186
+ Args:
187
+ email_data (dict[str, Any]): Raw email data from Gmail API.
188
+
189
+ Returns:
190
+ dict[str, str]: Parsed email details
191
+ """
192
+ payload = email_data.get("payload", {})
193
+ headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
194
+
195
+ body_data = _get_email_plain_text_body(payload)
196
+
197
+ email_details = {
198
+ "id": email_data.get("id", ""),
199
+ "thread_id": email_data.get("threadId", ""),
200
+ "label_ids": email_data.get("labelIds", []),
201
+ "history_id": email_data.get("historyId", ""),
202
+ "snippet": email_data.get("snippet", ""),
203
+ "to": headers.get("to", ""),
204
+ "cc": headers.get("cc", ""),
205
+ "from": headers.get("from", ""),
206
+ "reply_to": headers.get("reply-to", ""),
207
+ "in_reply_to": headers.get("in-reply-to", ""),
208
+ "references": headers.get("references", ""),
209
+ "header_message_id": headers.get("message-id", ""),
210
+ "date": headers.get("date", ""),
211
+ "subject": headers.get("subject", ""),
212
+ "body": body_data or "",
213
+ }
214
+
215
+ return email_details
216
+
217
+
218
+ def parse_multipart_email(email_data: dict[str, Any]) -> dict[str, Any]:
219
+ """
220
+ Parse email data and extract relevant information.
221
+ Returns the plain text and HTML body along with the images.
222
+
223
+ Args:
224
+ email_data (Dict[str, Any]): Raw email data from Gmail API.
225
+
226
+ Returns:
227
+ dict[str, Any]: Parsed email details
228
+ """
229
+
230
+ payload = email_data.get("payload", {})
231
+ headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
232
+
233
+ # Extract different parts of the email
234
+ plain_text_body = _get_email_plain_text_body(payload)
235
+ html_body = _get_email_html_body(payload)
236
+
237
+ email_details = {
238
+ "id": email_data.get("id", ""),
239
+ "thread_id": email_data.get("threadId", ""),
240
+ "label_ids": email_data.get("labelIds", []),
241
+ "history_id": email_data.get("historyId", ""),
242
+ "snippet": email_data.get("snippet", ""),
243
+ "to": headers.get("to", ""),
244
+ "cc": headers.get("cc", ""),
245
+ "from": headers.get("from", ""),
246
+ "reply_to": headers.get("reply-to", ""),
247
+ "in_reply_to": headers.get("in-reply-to", ""),
248
+ "references": headers.get("references", ""),
249
+ "header_message_id": headers.get("message-id", ""),
250
+ "date": headers.get("date", ""),
251
+ "subject": headers.get("subject", ""),
252
+ "plain_text_body": plain_text_body or _clean_email_body(html_body),
253
+ "html_body": html_body or "",
254
+ }
255
+
256
+ return email_details
257
+
258
+
259
+ def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]:
260
+ """
261
+ Parse draft email data and extract relevant information.
262
+
263
+ Args:
264
+ draft_email_data (Dict[str, Any]): Raw draft email data from Gmail API.
265
+
266
+ Returns:
267
+ dict[str, str]: Parsed draft email details
268
+ """
269
+ message = draft_email_data.get("message", {})
270
+ payload = message.get("payload", {})
271
+ headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
272
+
273
+ body_data = _get_email_plain_text_body(payload)
274
+
275
+ return {
276
+ "id": draft_email_data.get("id", ""),
277
+ "thread_id": draft_email_data.get("threadId", ""),
278
+ "from": headers.get("from", ""),
279
+ "date": headers.get("internaldate", ""),
280
+ "subject": headers.get("subject", ""),
281
+ "body": _clean_email_body(body_data) if body_data else "",
282
+ }
283
+
284
+
285
+ def get_draft_url(draft_id: str) -> str:
286
+ return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}"
287
+
288
+
289
+ def get_sent_email_url(sent_email_id: str) -> str:
290
+ return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}"
291
+
292
+
293
+ def get_email_details(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
294
+ """
295
+ Retrieves full message data for each message ID in the given list and extracts email details.
296
+
297
+ :param service: Authenticated Gmail API service instance.
298
+ :param messages: A list of dictionaries, each representing a message with an 'id' key.
299
+ :return: A list of dictionaries, each containing parsed email details.
300
+ """
301
+
302
+ emails = []
303
+ for msg in messages:
304
+ try:
305
+ # Fetch the full message data from Gmail using the message ID
306
+ email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
307
+ # Parse the raw email data into a structured form
308
+ email_details = parse_plain_text_email(email_data)
309
+ # Only add the details if parsing was successful
310
+ if email_details:
311
+ emails.append(email_details)
312
+ except Exception as e:
313
+ # Log any errors encountered while trying to fetch or parse a message
314
+ raise GmailToolError(
315
+ message=f"Error reading email {msg['id']}.", developer_message=str(e)
316
+ )
317
+ return emails
318
+
319
+
320
+ def get_email_in_trash_url(email_id: str) -> str:
321
+ return f"https://mail.google.com/mail/u/0/#trash/{email_id}"
322
+
323
+
324
+ def _build_gmail_service(context: ToolContext) -> Any:
325
+ """
326
+ Private helper function to build and return the Gmail service client.
327
+
328
+ Args:
329
+ context (ToolContext): The context containing authorization details.
330
+
331
+ Returns:
332
+ googleapiclient.discovery.Resource: An authorized Gmail API service instance.
333
+ """
334
+ try:
335
+ credentials = Credentials(
336
+ context.authorization.token
337
+ if context.authorization and context.authorization.token
338
+ else ""
339
+ )
340
+ except Exception as e:
341
+ raise GoogleServiceError(message="Failed to build Gmail service.", developer_message=str(e))
342
+
343
+ return build("gmail", "v1", credentials=credentials)
344
+
345
+
346
+ def _extract_plain_body(parts: list) -> str | None:
347
+ """
348
+ Recursively extract the email body from parts, handling both plain text and HTML.
349
+
350
+ Args:
351
+ parts (List[Dict[str, Any]]): List of email parts.
352
+
353
+ Returns:
354
+ str | None: Decoded and cleaned email body or None if not found.
355
+ """
356
+ for part in parts:
357
+ mime_type = part.get("mimeType")
358
+
359
+ if mime_type == "text/plain" and "data" in part.get("body", {}):
360
+ return urlsafe_b64decode(part["body"]["data"]).decode()
361
+
362
+ elif mime_type.startswith("multipart/"):
363
+ subparts = part.get("parts", [])
364
+ body = _extract_plain_body(subparts)
365
+ if body:
366
+ return body
367
+
368
+ return _extract_html_body(parts)
369
+
370
+
371
+ def _extract_html_body(parts: list) -> str | None:
372
+ """
373
+ Recursively extract the email body from parts, handling only HTML.
374
+
375
+ Args:
376
+ parts (List[Dict[str, Any]]): List of email parts.
377
+
378
+ Returns:
379
+ str | None: Decoded and cleaned email body or None if not found.
380
+ """
381
+ for part in parts:
382
+ mime_type = part.get("mimeType")
383
+
384
+ if mime_type == "text/html" and "data" in part.get("body", {}):
385
+ html_content = urlsafe_b64decode(part["body"]["data"]).decode()
386
+ return html_content
387
+
388
+ elif mime_type.startswith("multipart/"):
389
+ subparts = part.get("parts", [])
390
+ body = _extract_html_body(subparts)
391
+ if body:
392
+ return body
393
+
394
+ return None
395
+
396
+
397
+ def _get_email_images(payload: dict[str, Any]) -> list[str] | None:
398
+ """
399
+ Extract the email images from an email payload.
400
+
401
+ Args:
402
+ payload (Dict[str, Any]): Email payload data.
403
+
404
+ Returns:
405
+ list[str] | None: List of decoded image contents or None if none found.
406
+ """
407
+ images = []
408
+ for part in payload.get("parts", []):
409
+ mime_type = part.get("mimeType")
410
+
411
+ if mime_type.startswith("image/") and "data" in part.get("body", {}):
412
+ image_content = part["body"]["data"]
413
+ images.append(image_content)
414
+
415
+ elif mime_type.startswith("multipart/"):
416
+ subparts = part.get("parts", [])
417
+ subimages = _get_email_images(subparts)
418
+ if subimages:
419
+ images.extend(subimages)
420
+
421
+ if images:
422
+ return images
423
+
424
+ return None
425
+
426
+
427
+ def _get_email_plain_text_body(payload: dict[str, Any]) -> str | None:
428
+ """
429
+ Extract email body from payload, handling 'multipart/alternative' parts.
430
+
431
+ Args:
432
+ payload (Dict[str, Any]): Email payload data.
433
+
434
+ Returns:
435
+ str | None: Decoded email body or None if not found.
436
+ """
437
+ # Direct body extraction
438
+ if "body" in payload and payload["body"].get("data"):
439
+ return _clean_email_body(urlsafe_b64decode(payload["body"]["data"]).decode())
440
+
441
+ # Handle multipart and alternative parts
442
+ return _clean_email_body(_extract_plain_body(payload.get("parts", [])))
443
+
444
+
445
+ def _get_email_html_body(payload: dict[str, Any]) -> str | None:
446
+ """
447
+ Extract email html body from payload, handling 'multipart/alternative' parts.
448
+
449
+ Args:
450
+ payload (Dict[str, Any]): Email payload data.
451
+
452
+ Returns:
453
+ str | None: Decoded email body or None if not found.
454
+ """
455
+ # Direct body extraction
456
+ if "body" in payload and payload["body"].get("data"):
457
+ return urlsafe_b64decode(payload["body"]["data"]).decode()
458
+
459
+ # Handle multipart and alternative parts
460
+ return _extract_html_body(payload.get("parts", []))
461
+
462
+
463
+ def _clean_email_body(body: str | None) -> str:
464
+ """
465
+ Remove HTML tags and clean up email body text while preserving most content.
466
+
467
+ Args:
468
+ body (str): The raw email body text.
469
+
470
+ Returns:
471
+ str: Cleaned email body text.
472
+ """
473
+ if not body:
474
+ return ""
475
+
476
+ try:
477
+ # Remove HTML tags using BeautifulSoup
478
+ soup = BeautifulSoup(body, "html.parser")
479
+ text = soup.get_text(separator=" ")
480
+
481
+ # Clean up the text
482
+ cleaned_text = _clean_text(text)
483
+
484
+ return cleaned_text.strip()
485
+ except Exception:
486
+ logger.exception("Error cleaning email body")
487
+ return body
488
+
489
+
490
+ def _clean_text(text: str) -> str:
491
+ """
492
+ Clean up the text while preserving most content.
493
+
494
+ Args:
495
+ text (str): The input text.
496
+
497
+ Returns:
498
+ str: Cleaned text.
499
+ """
500
+ # Replace multiple newlines with a single newline
501
+ text = re.sub(r"\n+", "\n", text)
502
+
503
+ # Replace multiple spaces with a single space
504
+ text = re.sub(r"\s+", " ", text)
505
+
506
+ # Remove leading/trailing whitespace from each line
507
+ text = "\n".join(line.strip() for line in text.split("\n"))
508
+
509
+ return text
510
+
511
+
512
+ def _update_datetime(day: Day | None, time: TimeSlot | None, time_zone: str) -> dict | None:
513
+ """
514
+ Update the datetime for a Google Calendar event.
515
+
516
+ Args:
517
+ day (Day | None): The day of the event.
518
+ time (TimeSlot | None): The time of the event.
519
+ time_zone (str): The time zone of the event.
520
+
521
+ Returns:
522
+ dict | None: The updated datetime for the event.
523
+ """
524
+ if day and time:
525
+ dt = datetime.combine(day.to_date(time_zone), time.to_time())
526
+ return {"dateTime": dt.isoformat(), "timeZone": time_zone}
527
+ return None
528
+
529
+
530
+ def build_gmail_query_string(
531
+ sender: str | None = None,
532
+ recipient: str | None = None,
533
+ subject: str | None = None,
534
+ body: str | None = None,
535
+ date_range: DateRange | None = None,
536
+ label: str | None = None,
537
+ ) -> str:
538
+ """Helper function to build a query string
539
+ for Gmail list_emails_by_header and search_threads tools.
540
+ """
541
+ query = []
542
+ if sender:
543
+ query.append(f"from:{sender}")
544
+ if recipient:
545
+ query.append(f"to:{recipient}")
546
+ if subject:
547
+ query.append(f"subject:{subject}")
548
+ if body:
549
+ query.append(body)
550
+ if date_range:
551
+ query.append(date_range.to_date_query())
552
+ if label:
553
+ query.append(f"label:{label}")
554
+ return " ".join(query)
555
+
556
+
557
+ def get_label_ids(service: Any, label_names: list[str]) -> dict[str, str]:
558
+ """
559
+ Retrieve label IDs for given label names.
560
+ Returns a dictionary mapping label names to their IDs.
561
+
562
+ Args:
563
+ service: Authenticated Gmail API service instance.
564
+ label_names: List of label names to retrieve IDs for.
565
+
566
+ Returns:
567
+ A dictionary mapping found label names to their corresponding IDs.
568
+ """
569
+ try:
570
+ # Fetch all existing labels from Gmail
571
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
572
+ except Exception as e:
573
+ raise GmailToolError(message="Failed to list labels.", developer_message=str(e)) from e
574
+
575
+ # Create a mapping from label names to their IDs
576
+ label_id_map = {label["name"]: label["id"] for label in labels}
577
+
578
+ found_labels = {}
579
+ for name in label_names:
580
+ label_id = label_id_map.get(name)
581
+ if label_id:
582
+ found_labels[name] = label_id
583
+ else:
584
+ logger.warning(f"Label '{name}' does not exist")
585
+
586
+ return found_labels
587
+
588
+
589
+ def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]:
590
+ """
591
+ Helper function to fetch messages from Gmail API for the list_emails_by_header tool.
592
+ """
593
+ response = (
594
+ service.users()
595
+ .messages()
596
+ .list(userId="me", q=query_string, maxResults=limit or 100)
597
+ .execute()
598
+ )
599
+ return response.get("messages", []) # type: ignore[no-any-return]
600
+
601
+
602
+ def remove_none_values(params: dict) -> dict:
603
+ """
604
+ Remove None values from a dictionary.
605
+ :param params: The dictionary to clean
606
+ :return: A new dictionary with None values removed
607
+ """
608
+ return {k: v for k, v in params.items() if v is not None}
609
+
610
+
611
+ # Drive utils
612
+ def build_drive_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
613
+ """
614
+ Build a Drive service object.
615
+ """
616
+ auth_token = auth_token or ""
617
+ return build("drive", "v3", credentials=Credentials(auth_token))
618
+
619
+
620
+ def build_files_list_query(
621
+ mime_type: str,
622
+ document_contains: list[str] | None = None,
623
+ document_not_contains: list[str] | None = None,
624
+ ) -> str:
625
+ query = [f"(mimeType = '{mime_type}' and trashed = false)"]
626
+
627
+ if isinstance(document_contains, str):
628
+ document_contains = [document_contains]
629
+
630
+ if isinstance(document_not_contains, str):
631
+ document_not_contains = [document_not_contains]
632
+
633
+ if document_contains:
634
+ for keyword in document_contains:
635
+ name_contains = keyword.replace("'", "\\'")
636
+ full_text_contains = keyword.replace("'", "\\'")
637
+ keyword_query = (
638
+ f"(name contains '{name_contains}' or fullText contains '{full_text_contains}')"
639
+ )
640
+ query.append(keyword_query)
641
+
642
+ if document_not_contains:
643
+ for keyword in document_not_contains:
644
+ name_not_contains = keyword.replace("'", "\\'")
645
+ full_text_not_contains = keyword.replace("'", "\\'")
646
+ keyword_query = (
647
+ f"(name not contains '{name_not_contains}' and "
648
+ f"fullText not contains '{full_text_not_contains}')"
649
+ )
650
+ query.append(keyword_query)
651
+
652
+ return " and ".join(query)
653
+
654
+
655
+ def build_files_list_params(
656
+ mime_type: str,
657
+ page_size: int,
658
+ order_by: list[OrderBy],
659
+ pagination_token: str | None,
660
+ include_shared_drives: bool,
661
+ search_only_in_shared_drive_id: str | None,
662
+ include_organization_domain_documents: bool,
663
+ document_contains: list[str] | None = None,
664
+ document_not_contains: list[str] | None = None,
665
+ ) -> dict[str, Any]:
666
+ query = build_files_list_query(
667
+ mime_type=mime_type,
668
+ document_contains=document_contains,
669
+ document_not_contains=document_not_contains,
670
+ )
671
+
672
+ params = {
673
+ "q": query,
674
+ "pageSize": page_size,
675
+ "orderBy": ",".join([item.value for item in order_by]),
676
+ "pageToken": pagination_token,
677
+ }
678
+
679
+ if (
680
+ include_shared_drives
681
+ or search_only_in_shared_drive_id
682
+ or include_organization_domain_documents
683
+ ):
684
+ params["includeItemsFromAllDrives"] = "true"
685
+ params["supportsAllDrives"] = "true"
686
+
687
+ if search_only_in_shared_drive_id:
688
+ params["driveId"] = search_only_in_shared_drive_id
689
+ params["corpora"] = Corpora.DRIVE.value
690
+
691
+ if include_organization_domain_documents:
692
+ params["corpora"] = Corpora.DOMAIN.value
693
+
694
+ params = remove_none_values(params)
695
+
696
+ return params
697
+
698
+
699
+ def build_file_tree_request_params(
700
+ order_by: list[OrderBy] | None,
701
+ page_token: str | None,
702
+ limit: int | None,
703
+ include_shared_drives: bool,
704
+ restrict_to_shared_drive_id: str | None,
705
+ include_organization_domain_documents: bool,
706
+ ) -> dict[str, Any]:
707
+ if order_by is None:
708
+ order_by = [OrderBy.MODIFIED_TIME_DESC]
709
+ elif isinstance(order_by, OrderBy):
710
+ order_by = [order_by]
711
+
712
+ params = {
713
+ "q": "trashed = false",
714
+ "corpora": Corpora.USER.value,
715
+ "pageToken": page_token,
716
+ "fields": (
717
+ "files(id, name, parents, mimeType, driveId, size, createdTime, modifiedTime, owners)"
718
+ ),
719
+ "orderBy": ",".join([item.value for item in order_by]),
720
+ }
721
+
722
+ if limit:
723
+ params["pageSize"] = str(limit)
724
+
725
+ if (
726
+ include_shared_drives
727
+ or restrict_to_shared_drive_id
728
+ or include_organization_domain_documents
729
+ ):
730
+ params["includeItemsFromAllDrives"] = "true"
731
+ params["supportsAllDrives"] = "true"
732
+
733
+ if restrict_to_shared_drive_id:
734
+ params["driveId"] = restrict_to_shared_drive_id
735
+ params["corpora"] = Corpora.DRIVE.value
736
+
737
+ if include_organization_domain_documents:
738
+ params["corpora"] = Corpora.DOMAIN.value
739
+
740
+ return params
741
+
742
+
743
+ def build_file_tree(files: dict[str, Any]) -> dict[str, Any]:
744
+ file_tree: dict[str, Any] = {}
745
+
746
+ for file in files.values():
747
+ owners = file.get("owners", [])
748
+ if owners:
749
+ owners = [
750
+ {"name": owner.get("displayName", ""), "email": owner.get("emailAddress", "")}
751
+ for owner in owners
752
+ ]
753
+ file["owners"] = owners
754
+
755
+ if "size" in file:
756
+ file["size"] = {"value": int(file["size"]), "unit": "bytes"}
757
+
758
+ # Although "parents" is a list, a file can only have one parent
759
+ try:
760
+ parent_id = file["parents"][0]
761
+ del file["parents"]
762
+ except (KeyError, IndexError):
763
+ parent_id = None
764
+
765
+ # Determine the file's Drive ID
766
+ if "driveId" in file:
767
+ drive_id = file["driveId"]
768
+ del file["driveId"]
769
+ # If a shared drive id is not present, the file is in "My Drive"
770
+ else:
771
+ drive_id = "My Drive"
772
+
773
+ if drive_id not in file_tree:
774
+ file_tree[drive_id] = []
775
+
776
+ # Root files will have the Drive's id as the parent. If the parent id is not in the files
777
+ # list, the file must be at drive's root
778
+ if parent_id not in files:
779
+ file_tree[drive_id].append(file)
780
+
781
+ # Associate the file with its parent
782
+ else:
783
+ if "children" not in files[parent_id]:
784
+ files[parent_id]["children"] = []
785
+ files[parent_id]["children"].append(file)
786
+
787
+ return file_tree
788
+
789
+
790
+ # Docs utils
791
+ def build_docs_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
792
+ """
793
+ Build a Drive service object.
794
+ """
795
+ auth_token = auth_token or ""
796
+ return build("docs", "v1", credentials=Credentials(auth_token))
797
+
798
+
799
+ def parse_rfc3339_datetime_str(dt_str: str, tz: timezone = timezone.utc) -> datetime:
800
+ """
801
+ Parse an RFC3339 datetime string into a timezone-aware datetime.
802
+ Converts a trailing 'Z' (UTC) into +00:00.
803
+ If the parsed datetime is naive, assume it is in the provided timezone.
804
+ """
805
+ if dt_str.endswith("Z"):
806
+ dt_str = dt_str[:-1] + "+00:00"
807
+ dt = datetime.fromisoformat(dt_str)
808
+ if dt.tzinfo is None:
809
+ dt = dt.replace(tzinfo=tz)
810
+ return dt
811
+
812
+
813
+ def merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]:
814
+ """
815
+ Given a list of (start, end) tuples, merge overlapping or adjacent intervals.
816
+ """
817
+ merged: list[tuple[datetime, datetime]] = []
818
+ for start, end in sorted(intervals, key=lambda x: x[0]):
819
+ if not merged:
820
+ merged.append((start, end))
821
+ else:
822
+ last_start, last_end = merged[-1]
823
+ if start <= last_end:
824
+ merged[-1] = (last_start, max(last_end, end))
825
+ else:
826
+ merged.append((start, end))
827
+ return merged
828
+
829
+
830
+ # Calendar utils
831
+
832
+
833
+ def build_oauth_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
834
+ """
835
+ Build an OAuth2 service object.
836
+ """
837
+ auth_token = auth_token or ""
838
+ return build("oauth2", "v2", credentials=Credentials(auth_token))
839
+
840
+
841
+ def build_calendar_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
842
+ """
843
+ Build a Calendar service object.
844
+ """
845
+ auth_token = auth_token or ""
846
+ return build("calendar", "v3", credentials=Credentials(auth_token))
847
+
848
+
849
+ def weekday_to_name(weekday: int) -> str:
850
+ return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][weekday]
851
+
852
+
853
+ def get_time_boundaries_for_date(
854
+ current_date: date,
855
+ global_start: datetime,
856
+ global_end: datetime,
857
+ start_time_boundary: time,
858
+ end_time_boundary: time,
859
+ tz: ZoneInfo,
860
+ ) -> tuple[datetime, datetime]:
861
+ """Compute the allowed start and end times for the given day, adjusting for global bounds."""
862
+ day_start_time = datetime.combine(current_date, start_time_boundary).replace(tzinfo=tz)
863
+ day_end_time = datetime.combine(current_date, end_time_boundary).replace(tzinfo=tz)
864
+
865
+ if current_date == global_start.date():
866
+ day_start_time = max(day_start_time, global_start)
867
+
868
+ if current_date == global_end.date():
869
+ day_end_time = min(day_end_time, global_end)
870
+
871
+ return day_start_time, day_end_time
872
+
873
+
874
+ def gather_busy_intervals(
875
+ busy_data: dict[str, Any],
876
+ day_start: datetime,
877
+ day_end: datetime,
878
+ business_tz: ZoneInfo,
879
+ ) -> list[tuple[datetime, datetime]]:
880
+ """
881
+ Collect busy intervals from all calendars that intersect with the day's business hours.
882
+ Busy intervals are clipped to lie within [day_start, day_end].
883
+ """
884
+ busy_intervals = []
885
+ for calendar in busy_data:
886
+ for slot in busy_data[calendar].get("busy", []):
887
+ slot_start = parse_rfc3339_datetime_str(slot["start"]).astimezone(business_tz)
888
+ slot_end = parse_rfc3339_datetime_str(slot["end"]).astimezone(business_tz)
889
+ if slot_end > day_start and slot_start < day_end:
890
+ busy_intervals.append((max(slot_start, day_start), min(slot_end, day_end)))
891
+ return busy_intervals
892
+
893
+
894
+ def subtract_busy_intervals(
895
+ business_start: datetime,
896
+ business_end: datetime,
897
+ busy_intervals: list[tuple[datetime, datetime]],
898
+ ) -> list[dict[str, Any]]:
899
+ """
900
+ Subtract the merged busy intervals from the business hours and return free time slots.
901
+ """
902
+ free_slots = []
903
+ merged_busy = merge_intervals(busy_intervals)
904
+
905
+ # If there are no busy intervals, return the entire business window as free.
906
+ if not merged_busy:
907
+ return [
908
+ {
909
+ "start": {
910
+ "datetime": business_start.isoformat(),
911
+ "weekday": weekday_to_name(business_start.weekday()),
912
+ },
913
+ "end": {
914
+ "datetime": business_end.isoformat(),
915
+ "weekday": weekday_to_name(business_end.weekday()),
916
+ },
917
+ }
918
+ ]
919
+
920
+ current_free_start = business_start
921
+ for busy_start, busy_end in merged_busy:
922
+ if current_free_start < busy_start:
923
+ free_slots.append({
924
+ "start": {
925
+ "datetime": current_free_start.isoformat(),
926
+ "weekday": weekday_to_name(current_free_start.weekday()),
927
+ },
928
+ "end": {
929
+ "datetime": busy_start.isoformat(),
930
+ "weekday": weekday_to_name(busy_start.weekday()),
931
+ },
932
+ })
933
+ current_free_start = max(current_free_start, busy_end)
934
+ if current_free_start < business_end:
935
+ free_slots.append({
936
+ "start": {
937
+ "datetime": current_free_start.isoformat(),
938
+ "weekday": weekday_to_name(current_free_start.weekday()),
939
+ },
940
+ "end": {
941
+ "datetime": business_end.isoformat(),
942
+ "weekday": weekday_to_name(business_end.weekday()),
943
+ },
944
+ })
945
+ return free_slots
946
+
947
+
948
+ def compute_free_time_intersection(
949
+ busy_data: dict[str, Any],
950
+ global_start: datetime,
951
+ global_end: datetime,
952
+ start_time_boundary: time,
953
+ end_time_boundary: time,
954
+ include_weekends: bool,
955
+ tz: ZoneInfo,
956
+ ) -> list[dict[str, Any]]:
957
+ """
958
+ Returns the free time slots across all calendars within the global bounds,
959
+ ensuring that the global start is not in the past.
960
+
961
+ Only considers business days (Monday to Friday) and business hours (08:00-19:00)
962
+ in the provided timezone.
963
+ """
964
+ # Ensure global_start is never in the past relative to now.
965
+ now = get_now(tz)
966
+
967
+ if now > global_start:
968
+ global_start = now
969
+
970
+ # If after adjusting the start, there's no interval left, return empty.
971
+ if global_start >= global_end:
972
+ return []
973
+
974
+ free_slots = []
975
+ current_date = global_start.date()
976
+
977
+ while current_date <= global_end.date():
978
+ if not include_weekends and current_date.weekday() >= 5:
979
+ current_date += timedelta(days=1)
980
+ continue
981
+
982
+ day_start, day_end = get_time_boundaries_for_date(
983
+ current_date=current_date,
984
+ global_start=global_start,
985
+ global_end=global_end,
986
+ start_time_boundary=start_time_boundary,
987
+ end_time_boundary=end_time_boundary,
988
+ tz=tz,
989
+ )
990
+
991
+ # Skip if the day's allowed time window is empty.
992
+ if day_start >= day_end:
993
+ current_date += timedelta(days=1)
994
+ continue
995
+
996
+ busy_intervals = gather_busy_intervals(busy_data, day_start, day_end, tz)
997
+ free_slots.extend(subtract_busy_intervals(day_start, day_end, busy_intervals))
998
+
999
+ current_date += timedelta(days=1)
1000
+
1001
+ return free_slots
1002
+
1003
+
1004
+ def get_now(tz: ZoneInfo | None = None) -> datetime:
1005
+ if not tz:
1006
+ tz = ZoneInfo("UTC")
1007
+ return datetime.now(tz)
1008
+
1009
+
1010
+ # Contacts utils
1011
+ def build_people_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
1012
+ """
1013
+ Build a People service object.
1014
+ """
1015
+ auth_token = auth_token or ""
1016
+ return build("people", "v1", credentials=Credentials(auth_token))
1017
+
1018
+
1019
+ def search_contacts(service: Any, query: str, limit: int | None) -> list[dict[str, Any]]:
1020
+ """
1021
+ Search the user's contacts in Google Contacts.
1022
+ """
1023
+ response = (
1024
+ service.people()
1025
+ .searchContacts(
1026
+ query=query,
1027
+ pageSize=limit or DEFAULT_SEARCH_CONTACTS_LIMIT,
1028
+ readMask=",".join([
1029
+ "names",
1030
+ "nicknames",
1031
+ "emailAddresses",
1032
+ "phoneNumbers",
1033
+ "addresses",
1034
+ "organizations",
1035
+ "biographies",
1036
+ "urls",
1037
+ "userDefined",
1038
+ ]),
1039
+ )
1040
+ .execute()
1041
+ )
1042
+
1043
+ return cast(list[dict[str, Any]], response.get("results", []))
1044
+
1045
+
1046
+ # ----------------------------------------------------------------
1047
+ # Sheets utils
1048
+ # ----------------------------------------------------------------
1049
+
1050
+
1051
+ def build_sheets_service(auth_token: str | None) -> Resource: # type: ignore[no-any-unimported]
1052
+ """
1053
+ Build a Sheets service object.
1054
+ """
1055
+ auth_token = auth_token or ""
1056
+ return build("sheets", "v4", credentials=Credentials(auth_token))
1057
+
1058
+
1059
+ def col_to_index(col: str) -> int:
1060
+ """Convert a sheet's column string to a 0-indexed column index
1061
+
1062
+ Args:
1063
+ col (str): The column string to convert. e.g., "A", "AZ", "QED"
1064
+
1065
+ Returns:
1066
+ int: The 0-indexed column index.
1067
+ """
1068
+ result = 0
1069
+ for char in col.upper():
1070
+ result = result * 26 + (ord(char) - ord("A") + 1)
1071
+ return result - 1
1072
+
1073
+
1074
+ def index_to_col(index: int) -> str:
1075
+ """Convert a 0-indexed column index to its corresponding column string
1076
+
1077
+ Args:
1078
+ index (int): The 0-indexed column index to convert.
1079
+
1080
+ Returns:
1081
+ str: The column string. e.g., "A", "AZ", "QED"
1082
+ """
1083
+ result = ""
1084
+ index += 1
1085
+ while index > 0:
1086
+ index, rem = divmod(index - 1, 26)
1087
+ result = chr(rem + ord("A")) + result
1088
+ return result
1089
+
1090
+
1091
+ def is_col_greater(col1: str, col2: str) -> bool:
1092
+ """Determine if col1 represents a column that comes after col2 in a sheet
1093
+
1094
+ This comparison is based on:
1095
+ 1. The length of the column string (longer means greater).
1096
+ 2. Lexicographical comparison if both strings are the same length.
1097
+
1098
+ Args:
1099
+ col1 (str): The first column string to compare.
1100
+ col2 (str): The second column string to compare.
1101
+
1102
+ Returns:
1103
+ bool: True if col1 comes after col2, False otherwise.
1104
+ """
1105
+ if len(col1) != len(col2):
1106
+ return len(col1) > len(col2)
1107
+ return col1.upper() > col2.upper()
1108
+
1109
+
1110
+ def compute_sheet_data_dimensions(
1111
+ sheet_data_input: SheetDataInput,
1112
+ ) -> tuple[tuple[int, int], tuple[int, int]]:
1113
+ """
1114
+ Compute the dimensions of a sheet based on the data provided.
1115
+
1116
+ Args:
1117
+ sheet_data_input (SheetDataInput):
1118
+ The data to compute the dimensions of.
1119
+
1120
+ Returns:
1121
+ tuple[tuple[int, int], tuple[int, int]]: The dimensions of the sheet. The first tuple
1122
+ contains the row range (start, end) and the second tuple contains the column range
1123
+ (start, end).
1124
+ """
1125
+ max_row = 0
1126
+ min_row = 10_000_000 # max number of cells in a sheet
1127
+ max_col_str = None
1128
+ min_col_str = None
1129
+
1130
+ for key, row in sheet_data_input.data.items():
1131
+ try:
1132
+ row_num = int(key)
1133
+ except ValueError:
1134
+ continue
1135
+ if row_num > max_row:
1136
+ max_row = row_num
1137
+ if row_num < min_row:
1138
+ min_row = row_num
1139
+
1140
+ if isinstance(row, dict):
1141
+ for col in row:
1142
+ # Update max column string
1143
+ if max_col_str is None or is_col_greater(col, max_col_str):
1144
+ max_col_str = col
1145
+ # Update min column string
1146
+ if min_col_str is None or is_col_greater(min_col_str, col):
1147
+ min_col_str = col
1148
+
1149
+ max_col_index = col_to_index(max_col_str) if max_col_str is not None else -1
1150
+ min_col_index = col_to_index(min_col_str) if min_col_str is not None else 0
1151
+
1152
+ return (min_row, max_row), (min_col_index, max_col_index)
1153
+
1154
+
1155
+ def create_sheet(sheet_data_input: SheetDataInput) -> Sheet:
1156
+ """Create a Google Sheet from a dictionary of data.
1157
+
1158
+ Args:
1159
+ sheet_data_input (SheetDataInput): The data to create the sheet from.
1160
+
1161
+ Returns:
1162
+ Sheet: The created sheet.
1163
+ """
1164
+ (_, max_row), (min_col_index, max_col_index) = compute_sheet_data_dimensions(sheet_data_input)
1165
+ sheet_data = create_sheet_data(sheet_data_input, min_col_index, max_col_index)
1166
+ sheet_properties = create_sheet_properties(
1167
+ row_count=max(DEFAULT_SHEET_ROW_COUNT, max_row),
1168
+ column_count=max(DEFAULT_SHEET_COLUMN_COUNT, max_col_index + 1),
1169
+ )
1170
+
1171
+ return Sheet(properties=sheet_properties, data=sheet_data)
1172
+
1173
+
1174
+ def create_sheet_properties(
1175
+ sheet_id: int = 1,
1176
+ title: str = "Sheet1",
1177
+ row_count: int = DEFAULT_SHEET_ROW_COUNT,
1178
+ column_count: int = DEFAULT_SHEET_COLUMN_COUNT,
1179
+ ) -> SheetProperties:
1180
+ """Create a SheetProperties object
1181
+
1182
+ Args:
1183
+ sheet_id (int): The ID of the sheet.
1184
+ title (str): The title of the sheet.
1185
+ row_count (int): The number of rows in the sheet.
1186
+ column_count (int): The number of columns in the sheet.
1187
+
1188
+ Returns:
1189
+ SheetProperties: The created sheet properties object.
1190
+ """
1191
+ return SheetProperties(
1192
+ sheetId=sheet_id,
1193
+ title=title,
1194
+ gridProperties=GridProperties(rowCount=row_count, columnCount=column_count),
1195
+ )
1196
+
1197
+
1198
+ def group_contiguous_rows(row_numbers: list[int]) -> list[list[int]]:
1199
+ """Groups a sorted list of row numbers into contiguous groups
1200
+
1201
+ A contiguous group is a list of row numbers that are consecutive integers.
1202
+ For example, [1,2,3,5,6] is converted to [[1,2,3],[5,6]].
1203
+
1204
+ Args:
1205
+ row_numbers (list[int]): The list of row numbers to group.
1206
+
1207
+ Returns:
1208
+ list[list[int]]: The grouped row numbers.
1209
+ """
1210
+ if not row_numbers:
1211
+ return []
1212
+ groups = []
1213
+ current_group = [row_numbers[0]]
1214
+ for r in row_numbers[1:]:
1215
+ if r == current_group[-1] + 1:
1216
+ current_group.append(r)
1217
+ else:
1218
+ groups.append(current_group)
1219
+ current_group = [r]
1220
+ groups.append(current_group)
1221
+ return groups
1222
+
1223
+
1224
+ def create_cell_data(cell_value: CellValue) -> CellData:
1225
+ """
1226
+ Create a CellData object based on the type of cell_value.
1227
+ """
1228
+ if isinstance(cell_value, bool):
1229
+ return _create_bool_cell(cell_value)
1230
+ elif isinstance(cell_value, int):
1231
+ return _create_int_cell(cell_value)
1232
+ elif isinstance(cell_value, float):
1233
+ return _create_float_cell(cell_value)
1234
+ elif isinstance(cell_value, str):
1235
+ return _create_string_cell(cell_value)
1236
+
1237
+
1238
+ def _create_formula_cell(cell_value: str) -> CellData:
1239
+ cell_val = CellExtendedValue(formulaValue=cell_value)
1240
+ return CellData(userEnteredValue=cell_val)
1241
+
1242
+
1243
+ def _create_currency_cell(cell_value: str) -> CellData:
1244
+ value_without_symbol = cell_value[1:]
1245
+ try:
1246
+ num_value = int(value_without_symbol)
1247
+ cell_format = CellFormat(
1248
+ numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0")
1249
+ )
1250
+ cell_val = CellExtendedValue(numberValue=num_value)
1251
+ return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
1252
+ except ValueError:
1253
+ try:
1254
+ num_value = float(value_without_symbol) # type: ignore[assignment]
1255
+ cell_format = CellFormat(
1256
+ numberFormat=NumberFormat(type=NumberFormatType.CURRENCY, pattern="$#,##0.00")
1257
+ )
1258
+ cell_val = CellExtendedValue(numberValue=num_value)
1259
+ return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
1260
+ except ValueError:
1261
+ return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
1262
+
1263
+
1264
+ def _create_percent_cell(cell_value: str) -> CellData:
1265
+ try:
1266
+ num_value = float(cell_value[:-1].strip())
1267
+ cell_format = CellFormat(
1268
+ numberFormat=NumberFormat(type=NumberFormatType.PERCENT, pattern="0.00%")
1269
+ )
1270
+ cell_val = CellExtendedValue(numberValue=num_value)
1271
+ return CellData(userEnteredValue=cell_val, userEnteredFormat=cell_format)
1272
+ except ValueError:
1273
+ return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
1274
+
1275
+
1276
+ def _create_bool_cell(cell_value: bool) -> CellData:
1277
+ return CellData(userEnteredValue=CellExtendedValue(boolValue=cell_value))
1278
+
1279
+
1280
+ def _create_int_cell(cell_value: int) -> CellData:
1281
+ cell_format = CellFormat(
1282
+ numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0")
1283
+ )
1284
+ return CellData(
1285
+ userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
1286
+ )
1287
+
1288
+
1289
+ def _create_float_cell(cell_value: float) -> CellData:
1290
+ cell_format = CellFormat(
1291
+ numberFormat=NumberFormat(type=NumberFormatType.NUMBER, pattern="#,##0.00")
1292
+ )
1293
+ return CellData(
1294
+ userEnteredValue=CellExtendedValue(numberValue=cell_value), userEnteredFormat=cell_format
1295
+ )
1296
+
1297
+
1298
+ def _create_string_cell(cell_value: str) -> CellData:
1299
+ if cell_value.startswith("="):
1300
+ return _create_formula_cell(cell_value)
1301
+ elif cell_value.startswith("$") and len(cell_value) > 1:
1302
+ return _create_currency_cell(cell_value)
1303
+ elif cell_value.endswith("%") and len(cell_value) > 1:
1304
+ return _create_percent_cell(cell_value)
1305
+
1306
+ return CellData(userEnteredValue=CellExtendedValue(stringValue=cell_value))
1307
+
1308
+
1309
+ def create_row_data(
1310
+ row_data: dict[str, CellValue], min_col_index: int, max_col_index: int
1311
+ ) -> RowData:
1312
+ """Constructs RowData for a single row using the provided row_data.
1313
+
1314
+ Args:
1315
+ row_data (dict[str, CellValue]): The data to create the row from.
1316
+ min_col_index (int): The minimum column index from the SheetDataInput.
1317
+ max_col_index (int): The maximum column index from the SheetDataInput.
1318
+ """
1319
+ row_cells = []
1320
+ for col_idx in range(min_col_index, max_col_index + 1):
1321
+ col_letter = index_to_col(col_idx)
1322
+ if col_letter in row_data:
1323
+ cell_data = create_cell_data(row_data[col_letter])
1324
+ else:
1325
+ cell_data = CellData(userEnteredValue=CellExtendedValue(stringValue=""))
1326
+ row_cells.append(cell_data)
1327
+ return RowData(values=row_cells)
1328
+
1329
+
1330
+ def create_sheet_data(
1331
+ sheet_data_input: SheetDataInput,
1332
+ min_col_index: int,
1333
+ max_col_index: int,
1334
+ ) -> list[GridData]:
1335
+ """Create grid data from SheetDataInput by grouping contiguous rows and processing cells.
1336
+
1337
+ Args:
1338
+ sheet_data_input (SheetDataInput): The data to create the sheet from.
1339
+ min_col_index (int): The minimum column index from the SheetDataInput.
1340
+ max_col_index (int): The maximum column index from the SheetDataInput.
1341
+
1342
+ Returns:
1343
+ list[GridData]: The created grid data.
1344
+ """
1345
+ row_numbers = list(sheet_data_input.data.keys())
1346
+ if not row_numbers:
1347
+ return []
1348
+
1349
+ sorted_rows = sorted(row_numbers)
1350
+ groups = group_contiguous_rows(sorted_rows)
1351
+
1352
+ sheet_data = []
1353
+ for group in groups:
1354
+ rows_data = []
1355
+ for r in group:
1356
+ current_row_data = sheet_data_input.data.get(r, {})
1357
+ row = create_row_data(current_row_data, min_col_index, max_col_index)
1358
+ rows_data.append(row)
1359
+ grid_data = GridData(
1360
+ startRow=group[0] - 1, # convert to 0-indexed
1361
+ startColumn=min_col_index,
1362
+ rowData=rows_data,
1363
+ )
1364
+ sheet_data.append(grid_data)
1365
+
1366
+ return sheet_data
1367
+
1368
+
1369
+ def parse_get_spreadsheet_response(api_response: dict) -> dict:
1370
+ """
1371
+ Parse the get spreadsheet Google Sheets API response into a structured dictionary.
1372
+ """
1373
+ properties = api_response.get("properties", {})
1374
+ sheets = [parse_sheet(sheet) for sheet in api_response.get("sheets", [])]
1375
+
1376
+ return {
1377
+ "title": properties.get("title", ""),
1378
+ "spreadsheetId": api_response.get("spreadsheetId", ""),
1379
+ "spreadsheetUrl": api_response.get("spreadsheetUrl", ""),
1380
+ "sheets": sheets,
1381
+ }
1382
+
1383
+
1384
+ def parse_sheet(api_sheet: dict) -> dict:
1385
+ """
1386
+ Parse an individual sheet's data from the Google Sheets 'get spreadsheet'
1387
+ API response into a structured dictionary.
1388
+ """
1389
+ props = api_sheet.get("properties", {})
1390
+ grid_props = props.get("gridProperties", {})
1391
+ cell_data = convert_api_grid_data_to_dict(api_sheet.get("data", []))
1392
+
1393
+ return {
1394
+ "sheetId": props.get("sheetId"),
1395
+ "title": props.get("title", ""),
1396
+ "rowCount": grid_props.get("rowCount", 0),
1397
+ "columnCount": grid_props.get("columnCount", 0),
1398
+ "data": cell_data,
1399
+ }
1400
+
1401
+
1402
+ def extract_user_entered_cell_value(cell: dict) -> Any:
1403
+ """
1404
+ Extract the user entered value from a cell's 'userEnteredValue'.
1405
+
1406
+ Args:
1407
+ cell (dict): A cell dictionary from the grid data.
1408
+
1409
+ Returns:
1410
+ The extracted value if present, otherwise None.
1411
+ """
1412
+ user_val = cell.get("userEnteredValue", {})
1413
+ for key in ["stringValue", "numberValue", "boolValue", "formulaValue"]:
1414
+ if key in user_val:
1415
+ return user_val[key]
1416
+
1417
+ return ""
1418
+
1419
+
1420
+ def process_row(row: dict, start_column_index: int) -> dict:
1421
+ """
1422
+ Process a single row from grid data, converting non-empty cells into a dictionary
1423
+ that maps column letters to cell values.
1424
+
1425
+ Args:
1426
+ row (dict): A row from the grid data.
1427
+ start_column_index (int): The starting column index for this row.
1428
+
1429
+ Returns:
1430
+ dict: A mapping of column letters to cell values for non-empty cells.
1431
+ """
1432
+ row_result = {}
1433
+ for j, cell in enumerate(row.get("values", [])):
1434
+ column_index = start_column_index + j
1435
+ column_string = index_to_col(column_index)
1436
+ user_entered_cell_value = extract_user_entered_cell_value(cell)
1437
+ formatted_cell_value = cell.get("formattedValue", "")
1438
+
1439
+ if user_entered_cell_value != "" or formatted_cell_value != "":
1440
+ row_result[column_string] = {
1441
+ "userEnteredValue": user_entered_cell_value,
1442
+ "formattedValue": formatted_cell_value,
1443
+ }
1444
+
1445
+ return row_result
1446
+
1447
+
1448
+ def convert_api_grid_data_to_dict(grids: list[dict]) -> dict:
1449
+ """
1450
+ Convert a list of grid data dictionaries from the 'get spreadsheet' API
1451
+ response into a structured cell dictionary.
1452
+
1453
+ The returned dictionary maps row numbers to sub-dictionaries that map column letters
1454
+ (e.g., 'A', 'B', etc.) to their corresponding non-empty cell values.
1455
+
1456
+ Args:
1457
+ grids (list[dict]): The list of grid data dictionaries from the API.
1458
+
1459
+ Returns:
1460
+ dict: A dictionary mapping row numbers to dictionaries of column letter/value pairs.
1461
+ Only includes non-empty rows and non-empty cells.
1462
+ """
1463
+ result = {}
1464
+ for grid in grids:
1465
+ start_row = grid.get("startRow", 0)
1466
+ start_column = grid.get("startColumn", 0)
1467
+
1468
+ for i, row in enumerate(grid.get("rowData", []), start=1):
1469
+ current_row = start_row + i
1470
+ row_data = process_row(row, start_column)
1471
+
1472
+ if row_data:
1473
+ result[current_row] = row_data
1474
+
1475
+ return dict(sorted(result.items()))
1476
+
1477
+
1478
+ def validate_write_to_cell_params( # type: ignore[no-any-unimported]
1479
+ service: Resource,
1480
+ spreadsheet_id: str,
1481
+ sheet_name: str,
1482
+ column: str,
1483
+ row: int,
1484
+ ) -> None:
1485
+ """Validates the input parameters for the write to cell tool.
1486
+
1487
+ Args:
1488
+ service (Resource): The Google Sheets service.
1489
+ spreadsheet_id (str): The ID of the spreadsheet provided to the tool.
1490
+ sheet_name (str): The name of the sheet provided to the tool.
1491
+ column (str): The column to write to provided to the tool.
1492
+ row (int): The row to write to provided to the tool.
1493
+
1494
+ Raises:
1495
+ RetryableToolError:
1496
+ If the sheet name is not found in the spreadsheet
1497
+ ToolExecutionError:
1498
+ If the column is not alphabetical
1499
+ If the row is not a positive number
1500
+ If the row is out of bounds for the sheet
1501
+ If the column is out of bounds for the sheet
1502
+ """
1503
+ if not column.isalpha():
1504
+ raise ToolExecutionError(
1505
+ message=(
1506
+ f"Invalid column name {column}. "
1507
+ "It must be a non-empty string containing only letters"
1508
+ ),
1509
+ )
1510
+
1511
+ if row < 1:
1512
+ raise ToolExecutionError(
1513
+ message=(f"Invalid row number {row}. It must be a positive integer greater than 0."),
1514
+ )
1515
+
1516
+ sheet_properties = (
1517
+ service.spreadsheets()
1518
+ .get(
1519
+ spreadsheetId=spreadsheet_id,
1520
+ includeGridData=True,
1521
+ fields="sheets/properties/title,sheets/properties/gridProperties/rowCount,sheets/properties/gridProperties/columnCount",
1522
+ )
1523
+ .execute()
1524
+ )
1525
+ sheet_names = [sheet["properties"]["title"] for sheet in sheet_properties["sheets"]]
1526
+ sheet_row_count = sheet_properties["sheets"][0]["properties"]["gridProperties"]["rowCount"]
1527
+ sheet_column_count = sheet_properties["sheets"][0]["properties"]["gridProperties"][
1528
+ "columnCount"
1529
+ ]
1530
+
1531
+ if sheet_name not in sheet_names:
1532
+ raise RetryableToolError(
1533
+ message=f"Sheet name {sheet_name} not found in spreadsheet with id {spreadsheet_id}",
1534
+ additional_prompt_content=f"Sheet names in the spreadsheet: {sheet_names}",
1535
+ retry_after_ms=100,
1536
+ )
1537
+
1538
+ if row > sheet_row_count:
1539
+ raise ToolExecutionError(
1540
+ message=(
1541
+ f"Row {row} is out of bounds for sheet {sheet_name} "
1542
+ f"in spreadsheet with id {spreadsheet_id}. "
1543
+ f"Sheet only has {sheet_row_count} rows which is less than the requested row {row}"
1544
+ )
1545
+ )
1546
+
1547
+ if col_to_index(column) > sheet_column_count:
1548
+ raise ToolExecutionError(
1549
+ message=(
1550
+ f"Column {column} is out of bounds for sheet {sheet_name} "
1551
+ f"in spreadsheet with id {spreadsheet_id}. "
1552
+ f"Sheet only has {sheet_column_count} columns which "
1553
+ f"is less than the requested column {column}"
1554
+ )
1555
+ )
1556
+
1557
+
1558
+ def parse_write_to_cell_response(response: dict) -> dict:
1559
+ return {
1560
+ "spreadsheetId": response["spreadsheetId"],
1561
+ "sheetTitle": response["updatedData"]["range"].split("!")[0],
1562
+ "updatedCell": response["updatedData"]["range"].split("!")[1],
1563
+ "value": response["updatedData"]["values"][0][0],
1564
+ }