workspace-mcp 1.1.5__py3-none-any.whl → 1.1.7__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.
core/utils.py CHANGED
@@ -3,11 +3,24 @@ import logging
3
3
  import os
4
4
  import tempfile
5
5
  import zipfile, xml.etree.ElementTree as ET
6
+ import ssl
7
+ import time
8
+ import asyncio
9
+ import functools
6
10
 
7
11
  from typing import List, Optional
8
12
 
13
+ from googleapiclient.errors import HttpError
14
+
9
15
  logger = logging.getLogger(__name__)
10
16
 
17
+
18
+ class TransientNetworkError(Exception):
19
+ """Custom exception for transient network errors after retries."""
20
+
21
+ pass
22
+
23
+
11
24
  def check_credentials_directory_permissions(credentials_dir: str = None) -> None:
12
25
  """
13
26
  Check if the service has appropriate permissions to create and write to the .credentials directory.
@@ -21,6 +34,7 @@ def check_credentials_directory_permissions(credentials_dir: str = None) -> None
21
34
  """
22
35
  if credentials_dir is None:
23
36
  from auth.google_auth import get_default_credentials_dir
37
+
24
38
  credentials_dir = get_default_credentials_dir()
25
39
 
26
40
  try:
@@ -29,22 +43,28 @@ def check_credentials_directory_permissions(credentials_dir: str = None) -> None
29
43
  # Directory exists, check if we can write to it
30
44
  test_file = os.path.join(credentials_dir, ".permission_test")
31
45
  try:
32
- with open(test_file, 'w') as f:
46
+ with open(test_file, "w") as f:
33
47
  f.write("test")
34
48
  os.remove(test_file)
35
- logger.info(f"Credentials directory permissions check passed: {os.path.abspath(credentials_dir)}")
49
+ logger.info(
50
+ f"Credentials directory permissions check passed: {os.path.abspath(credentials_dir)}"
51
+ )
36
52
  except (PermissionError, OSError) as e:
37
- raise PermissionError(f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}")
53
+ raise PermissionError(
54
+ f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}"
55
+ )
38
56
  else:
39
57
  # Directory doesn't exist, try to create it and its parent directories
40
58
  try:
41
59
  os.makedirs(credentials_dir, exist_ok=True)
42
60
  # Test writing to the new directory
43
61
  test_file = os.path.join(credentials_dir, ".permission_test")
44
- with open(test_file, 'w') as f:
62
+ with open(test_file, "w") as f:
45
63
  f.write("test")
46
64
  os.remove(test_file)
47
- logger.info(f"Created credentials directory with proper permissions: {os.path.abspath(credentials_dir)}")
65
+ logger.info(
66
+ f"Created credentials directory with proper permissions: {os.path.abspath(credentials_dir)}"
67
+ )
48
68
  except (PermissionError, OSError) as e:
49
69
  # Clean up if we created the directory but can't write to it
50
70
  try:
@@ -52,12 +72,17 @@ def check_credentials_directory_permissions(credentials_dir: str = None) -> None
52
72
  os.rmdir(credentials_dir)
53
73
  except:
54
74
  pass
55
- raise PermissionError(f"Cannot create or write to credentials directory '{os.path.abspath(credentials_dir)}': {e}")
75
+ raise PermissionError(
76
+ f"Cannot create or write to credentials directory '{os.path.abspath(credentials_dir)}': {e}"
77
+ )
56
78
 
57
79
  except PermissionError:
58
80
  raise
59
81
  except Exception as e:
60
- raise OSError(f"Unexpected error checking credentials directory permissions: {e}")
82
+ raise OSError(
83
+ f"Unexpected error checking credentials directory permissions: {e}"
84
+ )
85
+
61
86
 
62
87
  def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
63
88
  """
@@ -66,23 +91,38 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
66
91
  No external deps – just std-lib zipfile + ElementTree.
67
92
  """
68
93
  shared_strings: List[str] = []
69
- ns_excel_main = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
94
+ ns_excel_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
70
95
 
71
96
  try:
72
97
  with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
73
98
  targets: List[str] = []
74
99
  # Map MIME → iterable of XML files to inspect
75
- if mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
100
+ if (
101
+ mime_type
102
+ == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
103
+ ):
76
104
  targets = ["word/document.xml"]
77
- elif mime_type == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
105
+ elif (
106
+ mime_type
107
+ == "application/vnd.openxmlformats-officedocument.presentationml.presentation"
108
+ ):
78
109
  targets = [n for n in zf.namelist() if n.startswith("ppt/slides/slide")]
79
- elif mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
80
- targets = [n for n in zf.namelist() if n.startswith("xl/worksheets/sheet") and "drawing" not in n]
110
+ elif (
111
+ mime_type
112
+ == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
113
+ ):
114
+ targets = [
115
+ n
116
+ for n in zf.namelist()
117
+ if n.startswith("xl/worksheets/sheet") and "drawing" not in n
118
+ ]
81
119
  # Attempt to parse sharedStrings.xml for Excel files
82
120
  try:
83
121
  shared_strings_xml = zf.read("xl/sharedStrings.xml")
84
122
  shared_strings_root = ET.fromstring(shared_strings_xml)
85
- for si_element in shared_strings_root.findall(f"{{{ns_excel_main}}}si"):
123
+ for si_element in shared_strings_root.findall(
124
+ f"{{{ns_excel_main}}}si"
125
+ ):
86
126
  text_parts = []
87
127
  # Find all <t> elements, simple or within <r> runs, and concatenate their text
88
128
  for t_element in si_element.findall(f".//{{{ns_excel_main}}}t"):
@@ -90,11 +130,18 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
90
130
  text_parts.append(t_element.text)
91
131
  shared_strings.append("".join(text_parts))
92
132
  except KeyError:
93
- logger.info("No sharedStrings.xml found in Excel file (this is optional).")
133
+ logger.info(
134
+ "No sharedStrings.xml found in Excel file (this is optional)."
135
+ )
94
136
  except ET.ParseError as e:
95
137
  logger.error(f"Error parsing sharedStrings.xml: {e}")
96
- except Exception as e: # Catch any other unexpected error during sharedStrings parsing
97
- logger.error(f"Unexpected error processing sharedStrings.xml: {e}", exc_info=True)
138
+ except (
139
+ Exception
140
+ ) as e: # Catch any other unexpected error during sharedStrings parsing
141
+ logger.error(
142
+ f"Unexpected error processing sharedStrings.xml: {e}",
143
+ exc_info=True,
144
+ )
98
145
  else:
99
146
  return None
100
147
 
@@ -105,93 +152,145 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
105
152
  xml_root = ET.fromstring(xml_content)
106
153
  member_texts: List[str] = []
107
154
 
108
- if mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
109
- for cell_element in xml_root.findall(f".//{{{ns_excel_main}}}c"): # Find all <c> elements
110
- value_element = cell_element.find(f"{{{ns_excel_main}}}v") # Find <v> under <c>
155
+ if (
156
+ mime_type
157
+ == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
158
+ ):
159
+ for cell_element in xml_root.findall(
160
+ f".//{{{ns_excel_main}}}c"
161
+ ): # Find all <c> elements
162
+ value_element = cell_element.find(
163
+ f"{{{ns_excel_main}}}v"
164
+ ) # Find <v> under <c>
111
165
 
112
166
  # Skip if cell has no value element or value element has no text
113
167
  if value_element is None or value_element.text is None:
114
168
  continue
115
169
 
116
- cell_type = cell_element.get('t')
117
- if cell_type == 's': # Shared string
170
+ cell_type = cell_element.get("t")
171
+ if cell_type == "s": # Shared string
118
172
  try:
119
173
  ss_idx = int(value_element.text)
120
174
  if 0 <= ss_idx < len(shared_strings):
121
175
  member_texts.append(shared_strings[ss_idx])
122
176
  else:
123
- logger.warning(f"Invalid shared string index {ss_idx} in {member}. Max index: {len(shared_strings)-1}")
177
+ logger.warning(
178
+ f"Invalid shared string index {ss_idx} in {member}. Max index: {len(shared_strings)-1}"
179
+ )
124
180
  except ValueError:
125
- logger.warning(f"Non-integer shared string index: '{value_element.text}' in {member}.")
181
+ logger.warning(
182
+ f"Non-integer shared string index: '{value_element.text}' in {member}."
183
+ )
126
184
  else: # Direct value (number, boolean, inline string if not 's')
127
185
  member_texts.append(value_element.text)
128
186
  else: # Word or PowerPoint
129
187
  for elem in xml_root.iter():
130
188
  # For Word: <w:t> where w is "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
131
189
  # For PowerPoint: <a:t> where a is "http://schemas.openxmlformats.org/drawingml/2006/main"
132
- if elem.tag.endswith("}t") and elem.text: # Check for any namespaced tag ending with 't'
190
+ if (
191
+ elem.tag.endswith("}t") and elem.text
192
+ ): # Check for any namespaced tag ending with 't'
133
193
  cleaned_text = elem.text.strip()
134
- if cleaned_text: # Add only if there's non-whitespace text
135
- member_texts.append(cleaned_text)
194
+ if (
195
+ cleaned_text
196
+ ): # Add only if there's non-whitespace text
197
+ member_texts.append(cleaned_text)
136
198
 
137
199
  if member_texts:
138
- pieces.append(" ".join(member_texts)) # Join texts from one member with spaces
200
+ pieces.append(
201
+ " ".join(member_texts)
202
+ ) # Join texts from one member with spaces
139
203
 
140
204
  except ET.ParseError as e:
141
- logger.warning(f"Could not parse XML in member '{member}' for {mime_type} file: {e}")
205
+ logger.warning(
206
+ f"Could not parse XML in member '{member}' for {mime_type} file: {e}"
207
+ )
142
208
  except Exception as e:
143
- logger.error(f"Error processing member '{member}' for {mime_type}: {e}", exc_info=True)
209
+ logger.error(
210
+ f"Error processing member '{member}' for {mime_type}: {e}",
211
+ exc_info=True,
212
+ )
144
213
  # continue processing other members
145
214
 
146
- if not pieces: # If no text was extracted at all
215
+ if not pieces: # If no text was extracted at all
147
216
  return None
148
217
 
149
218
  # Join content from different members (sheets/slides) with double newlines for separation
150
219
  text = "\n\n".join(pieces).strip()
151
- return text or None # Ensure None is returned if text is empty after strip
220
+ return text or None # Ensure None is returned if text is empty after strip
152
221
 
153
222
  except zipfile.BadZipFile:
154
223
  logger.warning(f"File is not a valid ZIP archive (mime_type: {mime_type}).")
155
224
  return None
156
- except ET.ParseError as e: # Catch parsing errors at the top level if zipfile itself is XML-like
225
+ except (
226
+ ET.ParseError
227
+ ) as e: # Catch parsing errors at the top level if zipfile itself is XML-like
157
228
  logger.error(f"XML parsing error at a high level for {mime_type}: {e}")
158
229
  return None
159
230
  except Exception as e:
160
- logger.error(f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True)
231
+ logger.error(
232
+ f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True
233
+ )
161
234
  return None
162
235
 
163
- import functools
164
- from googleapiclient.errors import HttpError
165
236
 
166
- def handle_http_errors(tool_name: str):
237
+ def handle_http_errors(tool_name: str, is_read_only: bool = False):
167
238
  """
168
- A decorator to handle Google API HttpErrors in a standardized way.
239
+ A decorator to handle Google API HttpErrors and transient SSL errors in a standardized way.
169
240
 
170
241
  It wraps a tool function, catches HttpError, logs a detailed error message,
171
242
  and raises a generic Exception with a user-friendly message.
172
243
 
244
+ If is_read_only is True, it will also catch ssl.SSLError and retry with
245
+ exponential backoff. After exhausting retries, it raises a TransientNetworkError.
246
+
173
247
  Args:
174
248
  tool_name (str): The name of the tool being decorated (e.g., 'list_calendars').
175
- This is used for logging purposes.
249
+ is_read_only (bool): If True, the operation is considered safe to retry on
250
+ transient network errors. Defaults to False.
176
251
  """
252
+
177
253
  def decorator(func):
178
254
  @functools.wraps(func)
179
255
  async def wrapper(*args, **kwargs):
180
- try:
181
- return await func(*args, **kwargs)
182
- except HttpError as error:
183
- user_google_email = kwargs.get('user_google_email', 'N/A')
184
- message = (
185
- f"API error in {tool_name}: {error}. "
186
- f"You might need to re-authenticate for user '{user_google_email}'. "
187
- f"LLM: Try 'start_google_auth' with the user's email and the appropriate service_name."
188
- )
189
- logger.error(message, exc_info=True)
190
- raise Exception(message)
191
- except Exception as e:
192
- # Catch any other unexpected errors
193
- message = f"An unexpected error occurred in {tool_name}: {e}"
194
- logger.exception(message)
195
- raise Exception(message)
256
+ max_retries = 3
257
+ base_delay = 1
258
+
259
+ for attempt in range(max_retries):
260
+ try:
261
+ return await func(*args, **kwargs)
262
+ except ssl.SSLError as e:
263
+ if is_read_only and attempt < max_retries - 1:
264
+ delay = base_delay * (2**attempt)
265
+ logger.warning(
266
+ f"SSL error in {tool_name} on attempt {attempt + 1}: {e}. Retrying in {delay} seconds..."
267
+ )
268
+ await asyncio.sleep(delay)
269
+ else:
270
+ logger.error(
271
+ f"SSL error in {tool_name} on final attempt: {e}. Raising exception."
272
+ )
273
+ raise TransientNetworkError(
274
+ f"A transient SSL error occurred in '{tool_name}' after {max_retries} attempts. "
275
+ "This is likely a temporary network or certificate issue. Please try again shortly."
276
+ ) from e
277
+ except HttpError as error:
278
+ user_google_email = kwargs.get("user_google_email", "N/A")
279
+ message = (
280
+ f"API error in {tool_name}: {error}. "
281
+ f"You might need to re-authenticate for user '{user_google_email}'. "
282
+ f"LLM: Try 'start_google_auth' with the user's email and the appropriate service_name."
283
+ )
284
+ logger.error(message, exc_info=True)
285
+ raise Exception(message) from error
286
+ except TransientNetworkError:
287
+ # Re-raise without wrapping to preserve the specific error type
288
+ raise
289
+ except Exception as e:
290
+ message = f"An unexpected error occurred in {tool_name}: {e}"
291
+ logger.exception(message)
292
+ raise Exception(message) from e
293
+
196
294
  return wrapper
295
+
197
296
  return decorator
@@ -80,8 +80,8 @@ def _correct_time_format_for_api(
80
80
 
81
81
 
82
82
  @server.tool()
83
+ @handle_http_errors("list_calendars", is_read_only=True)
83
84
  @require_google_service("calendar", "calendar_read")
84
- @handle_http_errors("list_calendars")
85
85
  async def list_calendars(service, user_google_email: str) -> str:
86
86
  """
87
87
  Retrieves a list of calendars accessible to the authenticated user.
@@ -114,8 +114,8 @@ async def list_calendars(service, user_google_email: str) -> str:
114
114
 
115
115
 
116
116
  @server.tool()
117
+ @handle_http_errors("get_events", is_read_only=True)
117
118
  @require_google_service("calendar", "calendar_read")
118
- @handle_http_errors("get_events")
119
119
  async def get_events(
120
120
  service,
121
121
  user_google_email: str,
@@ -202,8 +202,8 @@ async def get_events(
202
202
 
203
203
 
204
204
  @server.tool()
205
- @require_google_service("calendar", "calendar_events")
206
205
  @handle_http_errors("create_event")
206
+ @require_google_service("calendar", "calendar_events")
207
207
  async def create_event(
208
208
  service,
209
209
  user_google_email: str,
@@ -326,8 +326,8 @@ async def create_event(
326
326
 
327
327
 
328
328
  @server.tool()
329
- @require_google_service("calendar", "calendar_events")
330
329
  @handle_http_errors("modify_event")
330
+ @require_google_service("calendar", "calendar_events")
331
331
  async def modify_event(
332
332
  service,
333
333
  user_google_email: str,
@@ -446,8 +446,8 @@ async def modify_event(
446
446
 
447
447
 
448
448
  @server.tool()
449
- @require_google_service("calendar", "calendar_events")
450
449
  @handle_http_errors("delete_event")
450
+ @require_google_service("calendar", "calendar_events")
451
451
  async def delete_event(service, user_google_email: str, event_id: str, calendar_id: str = "primary") -> str:
452
452
  """
453
453
  Deletes an existing event.
@@ -500,8 +500,8 @@ async def delete_event(service, user_google_email: str, event_id: str, calendar_
500
500
 
501
501
 
502
502
  @server.tool()
503
+ @handle_http_errors("get_event", is_read_only=True)
503
504
  @require_google_service("calendar", "calendar_read")
504
- @handle_http_errors("get_event")
505
505
  async def get_event(
506
506
  service,
507
507
  user_google_email: str,
gdocs/docs_tools.py CHANGED
@@ -20,8 +20,8 @@ from core.comments import create_comment_tools
20
20
  logger = logging.getLogger(__name__)
21
21
 
22
22
  @server.tool()
23
+ @handle_http_errors("search_docs", is_read_only=True)
23
24
  @require_google_service("drive", "drive_read")
24
- @handle_http_errors("search_docs")
25
25
  async def search_docs(
26
26
  service,
27
27
  user_google_email: str,
@@ -57,11 +57,11 @@ async def search_docs(
57
57
  return "\n".join(output)
58
58
 
59
59
  @server.tool()
60
+ @handle_http_errors("get_doc_content", is_read_only=True)
60
61
  @require_multiple_services([
61
62
  {"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
62
63
  {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}
63
64
  ])
64
- @handle_http_errors("get_doc_content")
65
65
  async def get_doc_content(
66
66
  drive_service,
67
67
  docs_service,
@@ -157,8 +157,8 @@ async def get_doc_content(
157
157
  return header + body_text
158
158
 
159
159
  @server.tool()
160
+ @handle_http_errors("list_docs_in_folder", is_read_only=True)
160
161
  @require_google_service("drive", "drive_read")
161
- @handle_http_errors("list_docs_in_folder")
162
162
  async def list_docs_in_folder(
163
163
  service,
164
164
  user_google_email: str,
@@ -189,8 +189,8 @@ async def list_docs_in_folder(
189
189
  return "\n".join(out)
190
190
 
191
191
  @server.tool()
192
- @require_google_service("docs", "docs_write")
193
192
  @handle_http_errors("create_doc")
193
+ @require_google_service("docs", "docs_write")
194
194
  async def create_doc(
195
195
  service,
196
196
  user_google_email: str,
gdrive/drive_tools.py CHANGED
@@ -76,8 +76,8 @@ def _build_drive_list_params(
76
76
  return list_params
77
77
 
78
78
  @server.tool()
79
+ @handle_http_errors("search_drive_files", is_read_only=True)
79
80
  @require_google_service("drive", "drive_read")
80
- @handle_http_errors("search_drive_files")
81
81
  async def search_drive_files(
82
82
  service,
83
83
  user_google_email: str,
@@ -143,8 +143,8 @@ async def search_drive_files(
143
143
  return text_output
144
144
 
145
145
  @server.tool()
146
+ @handle_http_errors("get_drive_file_content", is_read_only=True)
146
147
  @require_google_service("drive", "drive_read")
147
- @handle_http_errors("get_drive_file_content")
148
148
  async def get_drive_file_content(
149
149
  service,
150
150
  user_google_email: str,
@@ -200,7 +200,7 @@ async def get_drive_file_content(
200
200
  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
201
201
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
202
202
  }
203
-
203
+
204
204
  if mime_type in office_mime_types:
205
205
  office_text = extract_office_xml_text(file_content_bytes, mime_type)
206
206
  if office_text:
@@ -233,8 +233,8 @@ async def get_drive_file_content(
233
233
 
234
234
 
235
235
  @server.tool()
236
+ @handle_http_errors("list_drive_items", is_read_only=True)
236
237
  @require_google_service("drive", "drive_read")
237
- @handle_http_errors("list_drive_items")
238
238
  async def list_drive_items(
239
239
  service,
240
240
  user_google_email: str,
@@ -289,8 +289,8 @@ async def list_drive_items(
289
289
  return text_output
290
290
 
291
291
  @server.tool()
292
- @require_google_service("drive", "drive_file")
293
292
  @handle_http_errors("create_drive_file")
293
+ @require_google_service("drive", "drive_file")
294
294
  async def create_drive_file(
295
295
  service,
296
296
  user_google_email: str,
gforms/forms_tools.py CHANGED
@@ -18,8 +18,8 @@ logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
20
  @server.tool()
21
- @require_google_service("forms", "forms")
22
21
  @handle_http_errors("create_form")
22
+ @require_google_service("forms", "forms")
23
23
  async def create_form(
24
24
  service,
25
25
  user_google_email: str,
@@ -67,8 +67,8 @@ async def create_form(
67
67
 
68
68
 
69
69
  @server.tool()
70
+ @handle_http_errors("get_form", is_read_only=True)
70
71
  @require_google_service("forms", "forms")
71
- @handle_http_errors("get_form")
72
72
  async def get_form(
73
73
  service,
74
74
  user_google_email: str,
@@ -123,8 +123,8 @@ async def get_form(
123
123
 
124
124
 
125
125
  @server.tool()
126
- @require_google_service("forms", "forms")
127
126
  @handle_http_errors("set_publish_settings")
127
+ @require_google_service("forms", "forms")
128
128
  async def set_publish_settings(
129
129
  service,
130
130
  user_google_email: str,
@@ -161,8 +161,8 @@ async def set_publish_settings(
161
161
 
162
162
 
163
163
  @server.tool()
164
+ @handle_http_errors("get_form_response", is_read_only=True)
164
165
  @require_google_service("forms", "forms")
165
- @handle_http_errors("get_form_response")
166
166
  async def get_form_response(
167
167
  service,
168
168
  user_google_email: str,
@@ -215,8 +215,8 @@ async def get_form_response(
215
215
 
216
216
 
217
217
  @server.tool()
218
+ @handle_http_errors("list_form_responses", is_read_only=True)
218
219
  @require_google_service("forms", "forms")
219
- @handle_http_errors("list_form_responses")
220
220
  async def list_form_responses(
221
221
  service,
222
222
  user_google_email: str,