google-workspace-mcp 1.0.0__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.
Files changed (38) hide show
  1. google_workspace_mcp/__init__.py +3 -0
  2. google_workspace_mcp/__main__.py +43 -0
  3. google_workspace_mcp/app.py +8 -0
  4. google_workspace_mcp/auth/__init__.py +7 -0
  5. google_workspace_mcp/auth/gauth.py +62 -0
  6. google_workspace_mcp/config.py +60 -0
  7. google_workspace_mcp/prompts/__init__.py +3 -0
  8. google_workspace_mcp/prompts/calendar.py +36 -0
  9. google_workspace_mcp/prompts/drive.py +18 -0
  10. google_workspace_mcp/prompts/gmail.py +65 -0
  11. google_workspace_mcp/prompts/slides.py +40 -0
  12. google_workspace_mcp/resources/__init__.py +13 -0
  13. google_workspace_mcp/resources/calendar.py +79 -0
  14. google_workspace_mcp/resources/drive.py +93 -0
  15. google_workspace_mcp/resources/gmail.py +58 -0
  16. google_workspace_mcp/resources/sheets_resources.py +92 -0
  17. google_workspace_mcp/resources/slides.py +421 -0
  18. google_workspace_mcp/services/__init__.py +21 -0
  19. google_workspace_mcp/services/base.py +73 -0
  20. google_workspace_mcp/services/calendar.py +256 -0
  21. google_workspace_mcp/services/docs_service.py +388 -0
  22. google_workspace_mcp/services/drive.py +454 -0
  23. google_workspace_mcp/services/gmail.py +676 -0
  24. google_workspace_mcp/services/sheets_service.py +466 -0
  25. google_workspace_mcp/services/slides.py +959 -0
  26. google_workspace_mcp/tools/__init__.py +7 -0
  27. google_workspace_mcp/tools/calendar.py +229 -0
  28. google_workspace_mcp/tools/docs_tools.py +277 -0
  29. google_workspace_mcp/tools/drive.py +221 -0
  30. google_workspace_mcp/tools/gmail.py +344 -0
  31. google_workspace_mcp/tools/sheets_tools.py +322 -0
  32. google_workspace_mcp/tools/slides.py +478 -0
  33. google_workspace_mcp/utils/__init__.py +1 -0
  34. google_workspace_mcp/utils/markdown_slides.py +504 -0
  35. google_workspace_mcp-1.0.0.dist-info/METADATA +547 -0
  36. google_workspace_mcp-1.0.0.dist-info/RECORD +38 -0
  37. google_workspace_mcp-1.0.0.dist-info/WHEEL +4 -0
  38. google_workspace_mcp-1.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,256 @@
1
+ """
2
+ Google Calendar service implementation.
3
+ """
4
+
5
+ import logging
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ import pytz
10
+
11
+ from google_workspace_mcp.services.base import BaseGoogleService
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class CalendarService(BaseGoogleService):
17
+ """
18
+ Service for interacting with Google Calendar API.
19
+ """
20
+
21
+ def __init__(self):
22
+ """Initialize the Calendar service."""
23
+ super().__init__("calendar", "v3")
24
+
25
+ def list_calendars(self) -> list[dict[str, Any]]:
26
+ """
27
+ Lists all calendars accessible by the user.
28
+
29
+ Returns:
30
+ List of calendar objects with their metadata
31
+ """
32
+ try:
33
+ calendar_list = self.service.calendarList().list().execute()
34
+
35
+ calendars = []
36
+
37
+ for calendar in calendar_list.get("items", []):
38
+ if calendar.get("kind") == "calendar#calendarListEntry":
39
+ calendars.append(
40
+ {
41
+ "id": calendar.get("id"),
42
+ "summary": calendar.get("summary"),
43
+ "primary": calendar.get("primary", False),
44
+ "time_zone": calendar.get("timeZone"),
45
+ "etag": calendar.get("etag"),
46
+ "access_role": calendar.get("accessRole"),
47
+ }
48
+ )
49
+
50
+ return calendars
51
+
52
+ except Exception as e:
53
+ return self.handle_api_error("list_calendars", e)
54
+
55
+ def get_events(
56
+ self,
57
+ calendar_id: str = "primary",
58
+ time_min: str | None = None,
59
+ time_max: str | None = None,
60
+ max_results: int = 250,
61
+ show_deleted: bool = False,
62
+ ) -> list[dict[str, Any]]:
63
+ """
64
+ Retrieve calendar events within a specified time range.
65
+
66
+ Args:
67
+ calendar_id: ID of the calendar to query
68
+ time_min: Start time in RFC3339 format. Defaults to current time.
69
+ time_max: End time in RFC3339 format
70
+ max_results: Maximum number of events to return
71
+ show_deleted: Whether to include deleted events
72
+
73
+ Returns:
74
+ List of calendar events
75
+ """
76
+ try:
77
+ # If no time_min specified, use current time
78
+ if not time_min:
79
+ time_min = datetime.now(pytz.UTC).isoformat()
80
+
81
+ # Ensure max_results is within limits
82
+ max_results = min(max(1, max_results), 2500)
83
+
84
+ # Prepare parameters
85
+ params = {
86
+ "calendarId": calendar_id,
87
+ "timeMin": time_min,
88
+ "maxResults": max_results,
89
+ "singleEvents": True,
90
+ "orderBy": "startTime",
91
+ "showDeleted": show_deleted,
92
+ }
93
+
94
+ # Add optional time_max if specified
95
+ if time_max:
96
+ params["timeMax"] = time_max
97
+
98
+ # Execute the events().list() method
99
+ events_result = self.service.events().list(**params).execute()
100
+
101
+ # Extract the events
102
+ events = events_result.get("items", [])
103
+
104
+ # Process and return the events
105
+ processed_events = []
106
+ for event in events:
107
+ processed_event = {
108
+ "id": event.get("id"),
109
+ "summary": event.get("summary"),
110
+ "description": event.get("description"),
111
+ "start": event.get("start"),
112
+ "end": event.get("end"),
113
+ "status": event.get("status"),
114
+ "creator": event.get("creator"),
115
+ "organizer": event.get("organizer"),
116
+ "attendees": event.get("attendees"),
117
+ "location": event.get("location"),
118
+ "hangoutLink": event.get("hangoutLink"),
119
+ "conferenceData": event.get("conferenceData"),
120
+ "recurringEventId": event.get("recurringEventId"),
121
+ }
122
+ processed_events.append(processed_event)
123
+
124
+ return processed_events
125
+
126
+ except Exception as e:
127
+ return self.handle_api_error("get_events", e)
128
+
129
+ def create_event(
130
+ self,
131
+ summary: str,
132
+ start_time: str,
133
+ end_time: str,
134
+ location: str | None = None,
135
+ description: str | None = None,
136
+ attendees: list[str] | None = None,
137
+ send_notifications: bool = True,
138
+ timezone: str | None = None,
139
+ calendar_id: str = "primary",
140
+ ) -> dict[str, Any] | None:
141
+ """
142
+ Create a new calendar event.
143
+
144
+ Args:
145
+ summary: Title of the event
146
+ start_time: Start time in RFC3339 format
147
+ end_time: End time in RFC3339 format
148
+ location: Location of the event
149
+ description: Description of the event
150
+ attendees: List of attendee email addresses
151
+ send_notifications: Whether to send notifications to attendees
152
+ timezone: Timezone for the event (e.g. 'America/New_York')
153
+ calendar_id: ID of the calendar to create the event in
154
+
155
+ Returns:
156
+ Created event data or None if creation fails
157
+ """
158
+ try:
159
+ # Prepare event data
160
+ event = {
161
+ "summary": summary,
162
+ "start": {
163
+ "dateTime": start_time,
164
+ "timeZone": timezone or "UTC",
165
+ },
166
+ "end": {
167
+ "dateTime": end_time,
168
+ "timeZone": timezone or "UTC",
169
+ },
170
+ }
171
+
172
+ # Add optional fields if provided
173
+ if location:
174
+ event["location"] = location
175
+ if description:
176
+ event["description"] = description
177
+ if attendees:
178
+ event["attendees"] = [{"email": email} for email in attendees]
179
+
180
+ # Map boolean to required string for sendUpdates
181
+ send_updates_value = "all" if send_notifications else "none"
182
+
183
+ # Create the event
184
+ return (
185
+ self.service.events()
186
+ .insert(
187
+ calendarId=calendar_id,
188
+ body=event,
189
+ sendUpdates=send_updates_value,
190
+ )
191
+ .execute()
192
+ )
193
+
194
+ except Exception as e:
195
+ return self.handle_api_error("create_event", e)
196
+
197
+ def delete_event(
198
+ self,
199
+ event_id: str,
200
+ send_notifications: bool = True,
201
+ calendar_id: str = "primary",
202
+ ) -> bool:
203
+ """
204
+ Delete a calendar event by its ID.
205
+
206
+ Args:
207
+ event_id: The ID of the event to delete
208
+ send_notifications: Whether to send cancellation notifications to attendees
209
+ calendar_id: ID of the calendar containing the event
210
+
211
+ Returns:
212
+ True if deletion was successful, False otherwise
213
+ """
214
+ try:
215
+ # Map boolean to required string for sendUpdates
216
+ send_updates_value = "all" if send_notifications else "none"
217
+
218
+ self.service.events().delete(
219
+ calendarId=calendar_id,
220
+ eventId=event_id,
221
+ sendUpdates=send_updates_value,
222
+ ).execute()
223
+ return True
224
+
225
+ except Exception as e:
226
+ self.handle_api_error("delete_event", e)
227
+ return False
228
+
229
+ def get_event_details(
230
+ self, event_id: str, calendar_id: str = "primary"
231
+ ) -> dict[str, Any] | None:
232
+ """
233
+ Retrieves details for a specific event.
234
+
235
+ Args:
236
+ event_id: The ID of the event.
237
+ calendar_id: The ID of the calendar the event belongs to. Defaults to "primary".
238
+
239
+ Returns:
240
+ A dictionary containing the event details or an error dictionary.
241
+ """
242
+ try:
243
+ logger.info(
244
+ f"Fetching details for event ID: {event_id} from calendar: {calendar_id}"
245
+ )
246
+ event = (
247
+ self.service.events()
248
+ .get(calendarId=calendar_id, eventId=event_id)
249
+ .execute()
250
+ )
251
+ logger.info(
252
+ f"Successfully fetched details for event: {event.get('summary')}"
253
+ )
254
+ return event # Return the full event resource as per API
255
+ except Exception as e:
256
+ return self.handle_api_error("get_event_details", e)
@@ -0,0 +1,388 @@
1
+ """
2
+ Google Docs service implementation.
3
+ """
4
+
5
+ import io
6
+ import logging
7
+ from typing import Any
8
+
9
+ from googleapiclient.discovery import build
10
+ from googleapiclient.errors import HttpError
11
+ from googleapiclient.http import MediaIoBaseDownload
12
+
13
+ from google_workspace_mcp.auth import gauth
14
+ from google_workspace_mcp.services.base import BaseGoogleService
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class DocsService(BaseGoogleService):
20
+ """
21
+ Service for interacting with the Google Docs API.
22
+ """
23
+
24
+ def __init__(self):
25
+ """Initialize the Google Docs service."""
26
+ super().__init__("docs", "v1")
27
+ # Note: The Google Docs API client is built using 'docs', 'v1'.
28
+ # The actual API calls will be like self.service.documents().<method>
29
+
30
+ def create_document(self, title: str) -> dict[str, Any] | None:
31
+ """
32
+ Creates a new Google Document with the specified title.
33
+
34
+ Args:
35
+ title: The title for the new document.
36
+
37
+ Returns:
38
+ A dictionary containing the created document's ID and title, or an error dictionary.
39
+ """
40
+ try:
41
+ logger.info(f"Creating new Google Document with title: '{title}'")
42
+ body = {"title": title}
43
+ document = self.service.documents().create(body=body).execute()
44
+ # The response includes documentId, title, and other fields.
45
+ logger.info(f"Successfully created document: {document.get('title')} (ID: {document.get('documentId')})")
46
+ return {
47
+ "document_id": document.get("documentId"),
48
+ "title": document.get("title"),
49
+ "document_link": f"https://docs.google.com/document/d/{document.get('documentId')}/edit",
50
+ }
51
+ except HttpError as error:
52
+ logger.error(f"Error creating document '{title}': {error}")
53
+ return self.handle_api_error("create_document", error)
54
+ except Exception as e:
55
+ logger.exception(f"Unexpected error creating document '{title}'")
56
+ return {
57
+ "error": True,
58
+ "error_type": "unexpected_service_error",
59
+ "message": str(e),
60
+ "operation": "create_document",
61
+ }
62
+
63
+ def get_document_metadata(self, document_id: str) -> dict[str, Any] | None:
64
+ """
65
+ Retrieves metadata for a specific Google Document.
66
+
67
+ Args:
68
+ document_id: The ID of the Google Document.
69
+
70
+ Returns:
71
+ A dictionary containing document metadata (ID, title), or an error dictionary.
72
+ """
73
+ try:
74
+ logger.info(f"Fetching metadata for document ID: {document_id}")
75
+ # The 'fields' parameter can be used to specify which fields to return.
76
+ # e.g., "documentId,title,body,revisionId,suggestionsViewMode"
77
+ document = self.service.documents().get(documentId=document_id, fields="documentId,title").execute()
78
+ logger.info(
79
+ f"Successfully fetched metadata for document: {document.get('title')} (ID: {document.get('documentId')})"
80
+ )
81
+ return {
82
+ "document_id": document.get("documentId"),
83
+ "title": document.get("title"),
84
+ "document_link": f"https://docs.google.com/document/d/{document.get('documentId')}/edit",
85
+ }
86
+ except HttpError as error:
87
+ logger.error(f"Error fetching document metadata for ID {document_id}: {error}")
88
+ return self.handle_api_error("get_document_metadata", error)
89
+ except Exception as e:
90
+ logger.exception(f"Unexpected error fetching document metadata for ID {document_id}")
91
+ return {
92
+ "error": True,
93
+ "error_type": "unexpected_service_error",
94
+ "message": str(e),
95
+ "operation": "get_document_metadata",
96
+ }
97
+
98
+ def get_document_content_as_markdown(self, document_id: str) -> dict[str, Any] | None:
99
+ """
100
+ Retrieves the content of a Google Document as Markdown using Drive API export.
101
+
102
+ Args:
103
+ document_id: The ID of the Google Document (also used as Drive file ID).
104
+
105
+ Returns:
106
+ A dictionary with 'document_id' and 'markdown_content', or an error dictionary.
107
+ """
108
+ try:
109
+ logger.info(f"Attempting to export document ID: {document_id} as Markdown via Drive API.")
110
+
111
+ # Obtain credentials
112
+ credentials = gauth.get_credentials()
113
+ if not credentials:
114
+ logger.error("Failed to obtain credentials for Drive API export.")
115
+ return {
116
+ "error": True,
117
+ "error_type": "authentication_error",
118
+ "message": "Failed to obtain credentials.",
119
+ "operation": "get_document_content_as_markdown",
120
+ }
121
+
122
+ # Build a temporary Drive service client
123
+ drive_service_client = build("drive", "v3", credentials=credentials)
124
+
125
+ # Attempt to export as 'text/markdown'
126
+ # Not all environments or GDoc content might support 'text/markdown' perfectly.
127
+ # 'text/plain' is a safer fallback.
128
+ request = drive_service_client.files().export_media(fileId=document_id, mimeType="text/markdown")
129
+ fh = io.BytesIO()
130
+ downloader = MediaIoBaseDownload(fh, request)
131
+ done = False
132
+ content_bytes = None
133
+ while done is False:
134
+ status, done = downloader.next_chunk()
135
+ logger.debug(f"Download progress: {int(status.progress() * 100)}%")
136
+ content_bytes = fh.getvalue()
137
+
138
+ if content_bytes is None:
139
+ raise Exception("Failed to download exported content (bytes object is None).")
140
+
141
+ markdown_content = content_bytes.decode("utf-8")
142
+ logger.info(f"Successfully exported document ID: {document_id} to Markdown.")
143
+ return {"document_id": document_id, "markdown_content": markdown_content}
144
+
145
+ except HttpError as error:
146
+ # If markdown export fails, could try 'text/plain' as fallback or just report error
147
+ logger.warning(
148
+ f"HTTPError exporting document {document_id} as Markdown: {error}. Falling back to text/plain might be an option if this is a common issue for certain docs."
149
+ )
150
+ # For now, just return the error from the attempt.
151
+ return self.handle_api_error("get_document_content_as_markdown_drive_export", error)
152
+ except Exception as e:
153
+ logger.exception(f"Unexpected error exporting document {document_id} as Markdown: {e}")
154
+ return {
155
+ "error": True,
156
+ "error_type": "export_error",
157
+ "message": str(e),
158
+ "operation": "get_document_content_as_markdown",
159
+ }
160
+
161
+ def append_text(self, document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any] | None:
162
+ """
163
+ Appends text to the end of a Google Document.
164
+
165
+ Args:
166
+ document_id: The ID of the Google Document.
167
+ text: The text to append.
168
+ ensure_newline: If True and the document is not empty, prepends a newline to the text.
169
+
170
+ Returns:
171
+ A dictionary indicating success or an error dictionary.
172
+ """
173
+ try:
174
+ logger.info(f"Appending text to document ID: {document_id}. ensure_newline={ensure_newline}")
175
+
176
+ # To append at the end, we need to find the current end of the body segment.
177
+ # Get document to find end index. Fields "body(content(endIndex))" might be enough.
178
+ # A simpler approach for "append" is often to insert at the existing end index of the body.
179
+ # If the document is empty, Docs API might create a paragraph.
180
+ # If ensure_newline, and doc is not empty, prepend "\n" to text.
181
+
182
+ document = (
183
+ self.service.documents().get(documentId=document_id, fields="body(content(endIndex,paragraph))").execute()
184
+ )
185
+ end_index = (
186
+ document.get("body", {}).get("content", [])[-1].get("endIndex")
187
+ if document.get("body", {}).get("content")
188
+ else 1
189
+ )
190
+
191
+ text_to_insert = text
192
+ if ensure_newline and end_index > 1: # A new doc might have an end_index of 1 for the initial implicit paragraph
193
+ text_to_insert = "\n" + text
194
+
195
+ requests = [
196
+ {
197
+ "insertText": {
198
+ "location": {
199
+ "index": end_index - 1 # Insert before the final newline of the document/body
200
+ },
201
+ "text": text_to_insert,
202
+ }
203
+ }
204
+ ]
205
+
206
+ self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
207
+ logger.info(f"Successfully appended text to document ID: {document_id}")
208
+ return {
209
+ "document_id": document_id,
210
+ "success": True,
211
+ "operation": "append_text",
212
+ }
213
+ except HttpError as error:
214
+ logger.error(f"Error appending text to document {document_id}: {error}")
215
+ return self.handle_api_error("append_text", error)
216
+ except Exception as e:
217
+ logger.exception(f"Unexpected error appending text to document {document_id}")
218
+ return {
219
+ "error": True,
220
+ "error_type": "unexpected_service_error",
221
+ "message": str(e),
222
+ "operation": "append_text",
223
+ }
224
+
225
+ def prepend_text(self, document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any] | None:
226
+ """
227
+ Prepends text to the beginning of a Google Document.
228
+
229
+ Args:
230
+ document_id: The ID of the Google Document.
231
+ text: The text to prepend.
232
+ ensure_newline: If True and the document is not empty, appends a newline to the text.
233
+
234
+ Returns:
235
+ A dictionary indicating success or an error dictionary.
236
+ """
237
+ try:
238
+ logger.info(f"Prepending text to document ID: {document_id}. ensure_newline={ensure_newline}")
239
+
240
+ text_to_insert = text
241
+ # To prepend, we generally insert at index 1 (after the initial Body segment start).
242
+ # If ensure_newline is true, and the document isn't empty, add a newline *after* the prepended text.
243
+ # This requires checking if the document has existing content.
244
+ if ensure_newline:
245
+ document = self.service.documents().get(documentId=document_id, fields="body(content(endIndex))").execute()
246
+ current_content_exists = bool(document.get("body", {}).get("content"))
247
+ if current_content_exists:
248
+ text_to_insert = text + "\n"
249
+
250
+ requests = [
251
+ {
252
+ "insertText": {
253
+ "location": {
254
+ # Index 1 is typically the beginning of the document body content.
255
+ "index": 1
256
+ },
257
+ "text": text_to_insert,
258
+ }
259
+ }
260
+ ]
261
+
262
+ self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
263
+ logger.info(f"Successfully prepended text to document ID: {document_id}")
264
+ return {
265
+ "document_id": document_id,
266
+ "success": True,
267
+ "operation": "prepend_text",
268
+ }
269
+ except HttpError as error:
270
+ logger.error(f"Error prepending text to document {document_id}: {error}")
271
+ return self.handle_api_error("prepend_text", error)
272
+ except Exception as e:
273
+ logger.exception(f"Unexpected error prepending text to document {document_id}")
274
+ return {
275
+ "error": True,
276
+ "error_type": "unexpected_service_error",
277
+ "message": str(e),
278
+ "operation": "prepend_text",
279
+ }
280
+
281
+ def insert_text(
282
+ self,
283
+ document_id: str,
284
+ text: str,
285
+ index: int | None = None,
286
+ segment_id: str | None = None,
287
+ ) -> dict[str, Any] | None:
288
+ """
289
+ Inserts text at a specific location in a Google Document.
290
+
291
+ Args:
292
+ document_id: The ID of the Google Document.
293
+ text: The text to insert.
294
+ index: Optional. The 0-based index where the text should be inserted within the segment.
295
+ If None or for the main body, typically 1 for the beginning of content.
296
+ Behavior depends on document structure; precise indexing requires knowledge of element endIndices.
297
+ If inserting into an empty body, index 1 is typically used.
298
+ segment_id: Optional. The ID of the header, footer, footnote, or inline object body.
299
+ If None or empty, targets the main document body.
300
+
301
+ Returns:
302
+ A dictionary indicating success or an error dictionary.
303
+ """
304
+ try:
305
+ logger.info(f"Inserting text into document ID: {document_id} at index: {index}, segment: {segment_id}")
306
+
307
+ # Default to index 1 if not provided, which usually targets the start of the body content.
308
+ # For an empty document, this effectively adds text.
309
+ # For precise insertion into existing content, caller needs to provide a valid index.
310
+ insert_location_index = index if index is not None else 1
311
+
312
+ request = {
313
+ "insertText": {
314
+ "location": {"index": insert_location_index},
315
+ "text": text,
316
+ }
317
+ }
318
+ # Only add segmentId to location if it's provided
319
+ if segment_id:
320
+ request["insertText"]["location"]["segmentId"] = segment_id
321
+
322
+ requests = [request]
323
+
324
+ self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
325
+ logger.info(f"Successfully inserted text into document ID: {document_id}")
326
+ return {
327
+ "document_id": document_id,
328
+ "success": True,
329
+ "operation": "insert_text",
330
+ }
331
+ except HttpError as error:
332
+ logger.error(f"Error inserting text into document {document_id}: {error}")
333
+ return self.handle_api_error("insert_text", error)
334
+ except Exception as e:
335
+ logger.exception(f"Unexpected error inserting text into document {document_id}")
336
+ return {
337
+ "error": True,
338
+ "error_type": "unexpected_service_error",
339
+ "message": str(e),
340
+ "operation": "insert_text",
341
+ }
342
+
343
+ def batch_update(self, document_id: str, requests: list[dict]) -> dict[str, Any] | None:
344
+ """
345
+ Applies a list of update requests to the specified Google Document.
346
+
347
+ Args:
348
+ document_id: The ID of the Google Document.
349
+ requests: A list of Google Docs API request objects as dictionaries.
350
+ (e.g., InsertTextRequest, DeleteContentRangeRequest, etc.)
351
+
352
+ Returns:
353
+ The response from the batchUpdate API call (contains replies for each request)
354
+ or an error dictionary.
355
+ """
356
+ try:
357
+ logger.info(f"Executing batchUpdate for document ID: {document_id} with {len(requests)} requests.")
358
+ if not requests:
359
+ logger.warning(f"batchUpdate called with no requests for document ID: {document_id}")
360
+ return {
361
+ "document_id": document_id,
362
+ "replies": [],
363
+ "message": "No requests provided.",
364
+ }
365
+
366
+ response = self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
367
+ # The response object contains a list of 'replies', one for each request,
368
+ # or it might be empty if all requests were successful without specific replies.
369
+ # It also contains 'writeControl' and 'documentId'.
370
+ logger.info(
371
+ f"Successfully executed batchUpdate for document ID: {document_id}. Response contains {len(response.get('replies', []))} replies."
372
+ )
373
+ return {
374
+ "document_id": response.get("documentId"),
375
+ "replies": response.get("replies", []),
376
+ "write_control": response.get("writeControl"),
377
+ }
378
+ except HttpError as error:
379
+ logger.error(f"Error during batchUpdate for document {document_id}: {error}")
380
+ return self.handle_api_error("batch_update", error)
381
+ except Exception as e:
382
+ logger.exception(f"Unexpected error during batchUpdate for document {document_id}")
383
+ return {
384
+ "error": True,
385
+ "error_type": "unexpected_service_error",
386
+ "message": str(e),
387
+ "operation": "batch_update",
388
+ }