google-api-client-wrapper 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 (39) hide show
  1. google_api_client_wrapper-1.0.0.dist-info/METADATA +103 -0
  2. google_api_client_wrapper-1.0.0.dist-info/RECORD +39 -0
  3. google_api_client_wrapper-1.0.0.dist-info/WHEEL +5 -0
  4. google_api_client_wrapper-1.0.0.dist-info/licenses/LICENSE +21 -0
  5. google_api_client_wrapper-1.0.0.dist-info/top_level.txt +1 -0
  6. google_client/__init__.py +6 -0
  7. google_client/services/__init__.py +13 -0
  8. google_client/services/calendar/__init__.py +14 -0
  9. google_client/services/calendar/api_service.py +454 -0
  10. google_client/services/calendar/constants.py +48 -0
  11. google_client/services/calendar/exceptions.py +35 -0
  12. google_client/services/calendar/query_builder.py +314 -0
  13. google_client/services/calendar/types.py +403 -0
  14. google_client/services/calendar/utils.py +338 -0
  15. google_client/services/drive/__init__.py +13 -0
  16. google_client/services/drive/api_service.py +1133 -0
  17. google_client/services/drive/constants.py +37 -0
  18. google_client/services/drive/exceptions.py +60 -0
  19. google_client/services/drive/query_builder.py +385 -0
  20. google_client/services/drive/types.py +242 -0
  21. google_client/services/drive/utils.py +392 -0
  22. google_client/services/gmail/__init__.py +16 -0
  23. google_client/services/gmail/api_service.py +715 -0
  24. google_client/services/gmail/constants.py +6 -0
  25. google_client/services/gmail/exceptions.py +45 -0
  26. google_client/services/gmail/query_builder.py +408 -0
  27. google_client/services/gmail/types.py +285 -0
  28. google_client/services/gmail/utils.py +426 -0
  29. google_client/services/tasks/__init__.py +12 -0
  30. google_client/services/tasks/api_service.py +561 -0
  31. google_client/services/tasks/constants.py +32 -0
  32. google_client/services/tasks/exceptions.py +35 -0
  33. google_client/services/tasks/query_builder.py +324 -0
  34. google_client/services/tasks/types.py +156 -0
  35. google_client/services/tasks/utils.py +224 -0
  36. google_client/user_client.py +208 -0
  37. google_client/utils/__init__.py +0 -0
  38. google_client/utils/datetime.py +144 -0
  39. google_client/utils/validation.py +71 -0
@@ -0,0 +1,1133 @@
1
+ import io
2
+ import os
3
+ from typing import Optional, List, Dict, Any, Union, BinaryIO
4
+
5
+ from googleapiclient.errors import HttpError
6
+ from googleapiclient.http import MediaFileUpload, MediaIoBaseUpload, MediaIoBaseDownload
7
+
8
+ from .utils import convert_mime_type_to_downloadable, guess_extension
9
+ from .types import DriveFile, DriveFolder, Permission, DriveItem
10
+ from .query_builder import DriveQueryBuilder
11
+ from . import utils
12
+ from .constants import (
13
+ DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT, DEFAULT_FILE_FIELDS,
14
+ FOLDER_MIME_TYPE, DEFAULT_CHUNK_SIZE
15
+ )
16
+ from .exceptions import (
17
+ DriveError, FileNotFoundError, FolderNotFoundError, PermissionDeniedError, FileTooLargeError,
18
+ UploadFailedError, DownloadFailedError, SharingError, DrivePermissionError, InvalidQueryError
19
+ )
20
+
21
+
22
+ class DriveApiService:
23
+ """
24
+ Service layer for Drive API operations.
25
+ Contains all Drive API functionality following the user-centric approach.
26
+ """
27
+
28
+ def __init__(self, service: Any):
29
+ """
30
+ Initialize Drive service.
31
+
32
+ Args:
33
+ service: The Drive API service instance
34
+ """
35
+ self._service = service
36
+
37
+ def query(self) -> DriveQueryBuilder:
38
+ """
39
+ Create a new DriveQueryBuilder for building complex file queries with a fluent API.
40
+
41
+ Returns:
42
+ DriveQueryBuilder instance for method chaining
43
+
44
+ Example:
45
+ files = (user.drive.query()
46
+ .limit(50)
47
+ .in_folder("parent_folder_id")
48
+ .search("meeting")
49
+ .file_type("pdf")
50
+ .execute())
51
+ """
52
+ return DriveQueryBuilder(self)
53
+
54
+ def list(
55
+ self,
56
+ query: Optional[str] = None,
57
+ max_results: Optional[int] = DEFAULT_MAX_RESULTS,
58
+ order_by: Optional[str] = None,
59
+ fields: Optional[str] = None,
60
+ page_token: Optional[str] = None
61
+ ) -> List[DriveItem]:
62
+ """
63
+ List files and folders in Drive.
64
+
65
+ Args:
66
+ query: Drive API query string
67
+ max_results: Maximum number of items to return
68
+ order_by: Field to order results by
69
+ fields: Fields to include in response
70
+ page_token: Token for pagination
71
+
72
+ Returns:
73
+ List of DriveFile and DriveFolder objects
74
+
75
+ Raises:
76
+ DriveError: If the API request fails
77
+ """
78
+ try:
79
+ if max_results and (max_results < 1 or max_results > MAX_RESULTS_LIMIT):
80
+ raise ValueError(f"max_results must be between 1 and {MAX_RESULTS_LIMIT}")
81
+
82
+ request_params = {
83
+ 'pageSize': max_results or DEFAULT_MAX_RESULTS,
84
+ 'fields': f'nextPageToken, files({fields or DEFAULT_FILE_FIELDS})'
85
+ }
86
+
87
+ if query:
88
+ request_params['q'] = query
89
+ if order_by:
90
+ request_params['orderBy'] = order_by
91
+ if page_token:
92
+ request_params['pageToken'] = page_token
93
+
94
+ result = self._service.files().list(**request_params).execute()
95
+ files_data = result.get('files', [])
96
+
97
+ items = [utils.convert_api_file_to_correct_type(file_data) for file_data in files_data]
98
+ return items
99
+
100
+ except HttpError as e:
101
+ error_msg = f"Failed to list files: {e}"
102
+
103
+ if e.resp.status == 403:
104
+ raise PermissionDeniedError(f"Permission denied: {e}")
105
+ elif e.resp.status == 400:
106
+ raise InvalidQueryError(f"Invalid query: {e}")
107
+ else:
108
+ raise DriveError(error_msg)
109
+ except Exception as e:
110
+ error_msg = f"Unexpected error listing files: {e}"
111
+ raise DriveError(error_msg)
112
+
113
+ def get(self, item_id: str, fields: Optional[str] = None) -> DriveItem:
114
+ """
115
+ Get a file or folder by its id.
116
+
117
+ Args:
118
+ item_id: File id or folder id
119
+ fields: Fields to include in response
120
+
121
+ Returns:
122
+ DriveFile or DriveFolder object
123
+
124
+ Raises:
125
+ FileNotFoundError: If the file is not found
126
+ DriveError: If the API request fails
127
+ """
128
+ try:
129
+ request_params = {
130
+ 'fileId': item_id,
131
+ 'fields': fields or DEFAULT_FILE_FIELDS
132
+ }
133
+
134
+ result = self._service.files().get(**request_params).execute()
135
+ file_obj = utils.convert_api_file_to_correct_type(result)
136
+ return file_obj
137
+
138
+ except HttpError as e:
139
+ if e.resp.status == 404:
140
+ raise FileNotFoundError(f"File not found: {item_id}")
141
+ elif e.resp.status == 403:
142
+ raise PermissionDeniedError(f"Permission denied for file: {item_id}")
143
+ else:
144
+ error_msg = f"Failed to get file {item_id}: {e}"
145
+ raise DriveError(error_msg)
146
+ except Exception as e:
147
+ error_msg = f"Unexpected error getting file {item_id}: {e}"
148
+ raise DriveError(error_msg)
149
+
150
+ def upload_file(
151
+ self,
152
+ file_path: str,
153
+ name: Optional[str] = None,
154
+ parent_folder_id: Optional[str] = None,
155
+ description: Optional[str] = None,
156
+ mime_type: Optional[str] = None
157
+ ) -> DriveFile:
158
+ """
159
+ Upload a file to Drive.
160
+
161
+ Args:
162
+ file_path: Local path to the file to upload
163
+ name: Name for the file in Drive (defaults to filename)
164
+ parent_folder_id: ID of parent folder
165
+ description: File description
166
+ mime_type: MIME type (auto-detected if not provided)
167
+
168
+ Returns:
169
+ DriveFile object for the uploaded file
170
+
171
+ Raises:
172
+ FileNotFoundError: If the local file doesn't exist
173
+ UploadFailedError: If the upload fails
174
+ DriveError: If the API request fails
175
+ """
176
+ try:
177
+ if not os.path.exists(file_path):
178
+ raise FileNotFoundError(f"Local file not found: {file_path}")
179
+
180
+ file_name = name or os.path.basename(file_path)
181
+ file_mime_type = mime_type or utils.guess_mime_type(file_path)
182
+
183
+ metadata = utils.build_file_metadata(
184
+ name=utils.sanitize_filename(file_name),
185
+ parents=[parent_folder_id] if parent_folder_id else None,
186
+ description=description
187
+ )
188
+
189
+ media = MediaFileUpload(
190
+ file_path,
191
+ mimetype=file_mime_type,
192
+ resumable=True,
193
+ chunksize=DEFAULT_CHUNK_SIZE
194
+ )
195
+
196
+ result = self._service.files().create(
197
+ body=metadata,
198
+ media_body=media,
199
+ fields=DEFAULT_FILE_FIELDS
200
+ ).execute()
201
+
202
+ file_obj = utils.convert_api_file_to_drive_file(result)
203
+ return file_obj
204
+
205
+ except HttpError as e:
206
+ if e.resp.status == 403:
207
+ raise PermissionDeniedError(f"Permission denied uploading file: {e}")
208
+ elif e.resp.status == 413:
209
+ raise FileTooLargeError(f"File too large: {file_path}")
210
+ else:
211
+ error_msg = f"Failed to upload file {file_path}: {e}"
212
+ raise UploadFailedError(error_msg)
213
+ except Exception as e:
214
+ error_msg = f"Unexpected error uploading file {file_path}: {e}"
215
+ raise UploadFailedError(error_msg)
216
+
217
+ def upload_file_content(
218
+ self,
219
+ content: Union[str, bytes, BinaryIO],
220
+ name: str,
221
+ parent_folder_id: Optional[str] = None,
222
+ description: Optional[str] = None,
223
+ mime_type: str = "text/plain"
224
+ ) -> DriveFile:
225
+ """
226
+ Upload file content directly to Drive.
227
+
228
+ Args:
229
+ content: File content (string, bytes, or file-like object)
230
+ name: Name for the file in Drive
231
+ parent_folder_id: ID of parent folder
232
+ description: File description
233
+ mime_type: MIME type of the content
234
+
235
+ Returns:
236
+ DriveFile object for the uploaded file
237
+
238
+ Raises:
239
+ UploadFailedError: If the upload fails
240
+ DriveError: If the API request fails
241
+ """
242
+ try:
243
+ metadata = utils.build_file_metadata(
244
+ name=utils.sanitize_filename(name),
245
+ parents=[parent_folder_id] if parent_folder_id else None,
246
+ description=description
247
+ )
248
+
249
+ # Convert content to file-like object
250
+ if isinstance(content, str):
251
+ content_io = io.StringIO(content)
252
+ elif isinstance(content, bytes):
253
+ content_io = io.BytesIO(content)
254
+ else:
255
+ content_io = content
256
+
257
+ media = MediaIoBaseUpload(
258
+ content_io,
259
+ mimetype=mime_type,
260
+ resumable=True
261
+ )
262
+
263
+ result = self._service.files().create(
264
+ body=metadata,
265
+ media_body=media,
266
+ fields=DEFAULT_FILE_FIELDS
267
+ ).execute()
268
+
269
+ file_obj = utils.convert_api_file_to_drive_file(result)
270
+ return file_obj
271
+
272
+ except HttpError as e:
273
+ if e.resp.status == 403:
274
+ raise PermissionDeniedError(f"Permission denied uploading content: {e}")
275
+ else:
276
+ error_msg = f"Failed to upload content as {name}: {e}"
277
+ raise UploadFailedError(error_msg)
278
+ except Exception as e:
279
+ error_msg = f"Unexpected error uploading content as {name}: {e}"
280
+ raise UploadFailedError(error_msg)
281
+
282
+ def download_file(self, file: DriveFile, dest_directory: str, file_name: str = None) -> str:
283
+ """
284
+ Download a file from Drive to local disk.
285
+
286
+ Args:
287
+ file: DriveFile object to download
288
+ dest_directory: Local directory where to save the file
289
+ file_name: Optional file name with extension
290
+
291
+ Returns:
292
+ Local path of the downloaded file
293
+
294
+ Raises:
295
+ FileNotFoundError: If the file is not found
296
+ DownloadFailedError: If the download fails
297
+ DriveError: If the API request fails
298
+ """
299
+
300
+ # Create directory if it doesn't exist
301
+ os.makedirs(os.path.dirname(dest_directory), exist_ok=True)
302
+ file_path = ""
303
+ if not file_name:
304
+ file_name = file.name + guess_extension(file.mime_type)
305
+ file_path = os.path.join(dest_directory, file_name)
306
+
307
+ with open(file_path, "wb") as f:
308
+ f.write(self.download_file_content(file))
309
+
310
+ return dest_directory
311
+
312
+ def download_file_content(self, file: DriveFile) -> bytes:
313
+ """
314
+ Download file content as bytes.
315
+
316
+ Args:
317
+ file: DriveFile object to download
318
+
319
+ Returns:
320
+ File content as bytes
321
+
322
+ Raises:
323
+ FileNotFoundError: If the file is not found
324
+ DownloadFailedError: If the download fails
325
+ DriveError: If the API request fails
326
+ """
327
+ try:
328
+ content_io = io.BytesIO()
329
+
330
+ request = None
331
+ if file.is_google_doc():
332
+ request = self._service.files().export_media(
333
+ fileId=file.file_id, mimeType=convert_mime_type_to_downloadable(file.mime_type)
334
+ )
335
+ else:
336
+ request = self._service.files().get_media(fileId=file.file_id)
337
+
338
+ downloader = MediaIoBaseDownload(content_io, request)
339
+ done = False
340
+ while not done:
341
+ status, done = downloader.next_chunk()
342
+
343
+ content = content_io.getvalue()
344
+ return content
345
+
346
+ except HttpError as e:
347
+ if e.resp.status == 404:
348
+ raise FileNotFoundError(f"File not found: {file.file_id}")
349
+ elif e.resp.status == 403:
350
+ raise PermissionDeniedError(f"Permission denied downloading file: {file.file_id}")
351
+ else:
352
+ error_msg = f"Failed to download file content {file.file_id}: {e}"
353
+ raise DownloadFailedError(error_msg)
354
+ except Exception as e:
355
+ error_msg = f"Unexpected error downloading file content {file.file_id}: {e}"
356
+ raise DownloadFailedError(error_msg)
357
+
358
+ def create_folder(
359
+ self,
360
+ name: str,
361
+ parent_folder: Optional[DriveFolder] = None,
362
+ description: Optional[str] = None
363
+ ) -> DriveFolder:
364
+ """
365
+ Create a new folder in Drive.
366
+
367
+ Args:
368
+ name: Name of the folder
369
+ parent_folder: Parent DriveFolder (optional)
370
+ description: Folder description
371
+
372
+ Returns:
373
+ DriveFolder object for the created folder
374
+
375
+ Raises:
376
+ DriveError: If the API request fails
377
+ """
378
+ try:
379
+ parent_id = parent_folder.folder_id if parent_folder else None
380
+ metadata = utils.build_file_metadata(
381
+ name=utils.sanitize_filename(name),
382
+ parents=[parent_id] if parent_id else None,
383
+ description=description,
384
+ mimeType=FOLDER_MIME_TYPE
385
+ )
386
+
387
+ result = self._service.files().create(
388
+ body=metadata,
389
+ fields=DEFAULT_FILE_FIELDS
390
+ ).execute()
391
+
392
+ folder_obj = utils.convert_api_file_to_drive_folder(result)
393
+ return folder_obj
394
+
395
+ except HttpError as e:
396
+ if e.resp.status == 403:
397
+ raise PermissionDeniedError(f"Permission denied creating folder: {e}")
398
+ else:
399
+ error_msg = f"Failed to create folder {name}: {e}"
400
+ raise DriveError(error_msg)
401
+ except Exception as e:
402
+ error_msg = f"Unexpected error creating folder {name}: {e}"
403
+ raise DriveError(error_msg)
404
+
405
+ def delete(self, item: DriveItem) -> bool:
406
+ """
407
+ Delete a file or folder from Drive.
408
+
409
+ Args:
410
+ item: DriveItem object to delete
411
+
412
+ Returns:
413
+ True if deletion was successful
414
+
415
+ Raises:
416
+ FileNotFoundError: If the item is not found
417
+ DriveError: If the API request fails
418
+ """
419
+ try:
420
+ self._service.files().delete(fileId=item.item_id).execute()
421
+ return True
422
+
423
+ except HttpError as e:
424
+ if e.resp.status == 404:
425
+ raise FileNotFoundError(f"Item not found: {item.item_id}")
426
+ elif e.resp.status == 403:
427
+ raise PermissionDeniedError(f"Permission denied deleting item: {item.item_id}")
428
+ else:
429
+ error_msg = f"Failed to delete item {item.item_id}: {e}"
430
+ raise DriveError(error_msg)
431
+ except Exception as e:
432
+ error_msg = f"Unexpected error deleting item {item.item_id}: {e}"
433
+ raise DriveError(error_msg)
434
+
435
+ def copy(
436
+ self,
437
+ item: DriveItem,
438
+ new_name: Optional[str] = None,
439
+ parent_folder: Optional[DriveFolder] = None
440
+ ) -> DriveItem:
441
+ """
442
+ Copy a file or folder in Drive.
443
+
444
+ Args:
445
+ item: DriveItem object to copy
446
+ new_name: Name for the copied item
447
+ parent_folder: Parent DriveFolder for the copy
448
+
449
+ Returns:
450
+ DriveItem object for the copied item
451
+
452
+ Raises:
453
+ FileNotFoundError: If the source item is not found
454
+ DriveError: If the API request fails
455
+ """
456
+ try:
457
+ metadata = {}
458
+ if new_name:
459
+ metadata['name'] = utils.sanitize_filename(new_name)
460
+ if parent_folder:
461
+ metadata['parents'] = [parent_folder.folder_id]
462
+
463
+ result = self._service.files().copy(
464
+ fileId=item.item_id,
465
+ body=metadata,
466
+ fields=DEFAULT_FILE_FIELDS
467
+ ).execute()
468
+
469
+ copied_item = utils.convert_api_file_to_correct_type(result)
470
+ return copied_item
471
+
472
+ except HttpError as e:
473
+ if e.resp.status == 404:
474
+ raise FileNotFoundError(f"Item not found: {item.item_id}")
475
+ elif e.resp.status == 403:
476
+ raise PermissionDeniedError(f"Permission denied copying item: {item.item_id}")
477
+ else:
478
+ error_msg = f"Failed to copy item {item.item_id}: {e}"
479
+ raise DriveError(error_msg)
480
+ except Exception as e:
481
+ error_msg = f"Unexpected error copying item {item.item_id}: {e}"
482
+ raise DriveError(error_msg)
483
+
484
+ def rename(
485
+ self,
486
+ item: DriveItem,
487
+ name: Optional[str] = None,
488
+ ) -> DriveItem:
489
+ """
490
+ Rename a file or folder in Drive.
491
+
492
+ Args:
493
+ item: DriveItem object to update
494
+ name: New name for the item
495
+
496
+ Returns:
497
+ Updated DriveItem object
498
+
499
+ Raises:
500
+ FileNotFoundError: If the item is not found
501
+ DriveError: If the API request fails
502
+ """
503
+ try:
504
+ result = self._service.files().update(
505
+ fileId=item.item_id,
506
+ body={'name': utils.sanitize_filename(name)},
507
+ fields=DEFAULT_FILE_FIELDS
508
+ ).execute()
509
+
510
+ updated_item = utils.convert_api_file_to_correct_type(result)
511
+ return updated_item
512
+
513
+ except HttpError as e:
514
+ if e.resp.status == 404:
515
+ raise FileNotFoundError(f"Item not found: {item.item_id}")
516
+ elif e.resp.status == 403:
517
+ raise PermissionDeniedError(f"Permission denied renaming item: {item.item_id}")
518
+ else:
519
+ error_msg = f"Failed to rename item {item.item_id}: {e}"
520
+ raise DriveError(error_msg)
521
+ except Exception as e:
522
+ error_msg = f"Unexpected error renaming item {item.item_id}: {e}"
523
+ raise DriveError(error_msg)
524
+
525
+ def share(
526
+ self,
527
+ item: DriveItem,
528
+ email: str,
529
+ role: str = "reader",
530
+ notify: bool = True,
531
+ message: Optional[str] = None
532
+ ) -> Permission:
533
+ """
534
+ Share a file or folder with a user.
535
+
536
+ Args:
537
+ item: DriveItem object to share
538
+ email: Email address of the user to share with
539
+ role: Permission role (reader, writer, commenter)
540
+ notify: Whether to send notification email
541
+ message: Custom message to include in notification
542
+
543
+ Returns:
544
+ Permission object for the created permission
545
+
546
+ Raises:
547
+ FileNotFoundError: If the item is not found
548
+ SharingError: If sharing fails
549
+ DriveError: If the API request fails
550
+ """
551
+ try:
552
+ permission_metadata = {
553
+ 'type': 'user',
554
+ 'role': role,
555
+ 'emailAddress': email
556
+ }
557
+
558
+ result = self._service.permissions().create(
559
+ fileId=item.item_id,
560
+ body=permission_metadata,
561
+ sendNotificationEmail=notify,
562
+ emailMessage=message,
563
+ fields='*'
564
+ ).execute()
565
+
566
+ permission = utils.convert_api_permission_to_permission(result)
567
+ return permission
568
+
569
+ except HttpError as e:
570
+ if e.resp.status == 404:
571
+ raise FileNotFoundError(f"Item not found: {item.item_id}")
572
+ elif e.resp.status == 403:
573
+ raise PermissionDeniedError(f"Permission denied sharing item: {item.item_id}")
574
+ else:
575
+ error_msg = f"Failed to share item {item.item_id} with {email}: {e}"
576
+ raise SharingError(error_msg)
577
+ except Exception as e:
578
+ error_msg = f"Unexpected error sharing item {item.item_id} with {email}: {e}"
579
+ raise SharingError(error_msg)
580
+
581
+ def get_permissions(self, item: DriveItem) -> List[Permission]:
582
+ """
583
+ Get all permissions for a file or folder.
584
+
585
+ Args:
586
+ item: DriveItem object to get permissions for
587
+
588
+ Returns:
589
+ List of Permission objects
590
+
591
+ Raises:
592
+ FileNotFoundError: If the item is not found
593
+ DriveError: If the API request fails
594
+ """
595
+ try:
596
+ result = self._service.permissions().list(
597
+ fileId=item.item_id,
598
+ fields='permissions(*)'
599
+ ).execute()
600
+
601
+ permissions_data = result.get('permissions', [])
602
+ permissions = [utils.convert_api_permission_to_permission(perm)
603
+ for perm in permissions_data]
604
+ return permissions
605
+
606
+ except HttpError as e:
607
+ if e.resp.status == 404:
608
+ raise FileNotFoundError(f"Item not found: {item.item_id}")
609
+ elif e.resp.status == 403:
610
+ raise PermissionDeniedError(f"Permission denied getting permissions: {item.item_id}")
611
+ else:
612
+ error_msg = f"Failed to get permissions for item {item.item_id}: {e}"
613
+ raise DriveError(error_msg)
614
+ except Exception as e:
615
+ error_msg = f"Unexpected error getting permissions for item {item.item_id}: {e}"
616
+ raise DriveError(error_msg)
617
+
618
+ def remove_permission(self, item: DriveItem, permission_id: str) -> bool:
619
+ """
620
+ Remove a permission from a file or folder.
621
+
622
+ Args:
623
+ item: DriveItem object to remove permission from
624
+ permission_id: ID of the permission to remove
625
+
626
+ Returns:
627
+ True if removal was successful
628
+
629
+ Raises:
630
+ FileNotFoundError: If the item is not found
631
+ DrivePermissionError: If permission removal fails
632
+ DriveError: If the API request fails
633
+ """
634
+ try:
635
+ self._service.permissions().delete(
636
+ fileId=item.item_id,
637
+ permissionId=permission_id
638
+ ).execute()
639
+ return True
640
+
641
+ except HttpError as e:
642
+ if e.resp.status == 404:
643
+ raise FileNotFoundError(f"File or permission not found")
644
+ elif e.resp.status == 403:
645
+ raise PermissionDeniedError(f"Permission denied removing permission")
646
+ else:
647
+ error_msg = f"Failed to remove permission {permission_id}: {e}"
648
+ raise DrivePermissionError(error_msg)
649
+ except Exception as e:
650
+ error_msg = f"Unexpected error removing permission {permission_id}: {e}"
651
+ raise DrivePermissionError(error_msg)
652
+
653
+ def list_folder_contents(
654
+ self,
655
+ folder: DriveFolder,
656
+ include_folders: bool = True,
657
+ include_files: bool = True,
658
+ max_results: Optional[int] = DEFAULT_MAX_RESULTS,
659
+ order_by: Optional[str] = None
660
+ ) -> List[DriveItem]:
661
+ """
662
+ List all contents (files and/or folders) within a specific folder.
663
+
664
+ Args:
665
+ folder: DriveFolder object representing the folder
666
+ include_folders: Whether to include subfolders in results
667
+ include_files: Whether to include files in results
668
+ max_results: Maximum number of items to return
669
+ order_by: Field to order results by
670
+
671
+ Returns:
672
+ List of DriveFile and DriveFolder objects in the folder
673
+
674
+ Raises:
675
+ FolderNotFoundError: If the folder is not found
676
+ DriveError: If the API request fails
677
+ """
678
+
679
+ try:
680
+ query_builder = self.query().in_folder(folder.folder_id)
681
+
682
+ if include_folders and not include_files:
683
+ query_builder = query_builder.folders_only()
684
+ elif include_files and not include_folders:
685
+ query_builder = query_builder.files_only()
686
+
687
+ if max_results:
688
+ query_builder = query_builder.limit(max_results)
689
+
690
+ if order_by:
691
+ query_builder = query_builder.order_by(order_by)
692
+
693
+ contents = query_builder.execute()
694
+
695
+ return contents
696
+
697
+ except Exception as e:
698
+ if "not found" in str(e).lower():
699
+ raise FolderNotFoundError(f"Folder not found: {folder.folder_id}")
700
+ error_msg = f"Failed to list contents of folder {folder.folder_id}: {e}"
701
+ raise DriveError(error_msg)
702
+
703
+ def move(
704
+ self,
705
+ item: DriveItem,
706
+ target_folder: DriveFolder,
707
+ remove_from_current_parents: bool = True
708
+ ) -> DriveItem:
709
+ """
710
+ Move a file or folder to a different parent folder.
711
+
712
+ Args:
713
+ item: DriveItem object to move
714
+ target_folder: Target DriveFolder
715
+ remove_from_current_parents: Whether to remove from current parents
716
+
717
+ Returns:
718
+ Updated DriveItem object
719
+
720
+ Raises:
721
+ FileNotFoundError: If the item or target folder is not found
722
+ DriveError: If the API request fails
723
+ """
724
+ try:
725
+ # Prepare the update metadata
726
+ update_params = {
727
+ 'fileId': item.item_id,
728
+ 'addParents': target_folder.folder_id,
729
+ 'fields': DEFAULT_FILE_FIELDS
730
+ }
731
+
732
+ # Remove from current parents if requested
733
+ if remove_from_current_parents and item.parent_ids:
734
+ update_params['removeParents'] = ','.join(item.parent_ids)
735
+
736
+ result = self._service.files().update(**update_params).execute()
737
+
738
+ updated_item = utils.convert_api_file_to_correct_type(result)
739
+ return updated_item
740
+
741
+ except HttpError as e:
742
+ if e.resp.status == 404:
743
+ raise FileNotFoundError(f"File or folder not found")
744
+ elif e.resp.status == 403:
745
+ raise PermissionDeniedError(f"Permission denied moving file")
746
+ else:
747
+ error_msg = f"Failed to move item {item.item_id}: {e}"
748
+ raise DriveError(error_msg)
749
+ except Exception as e:
750
+ error_msg = f"Unexpected error moving item {item.item_id}: {e}"
751
+ raise DriveError(error_msg)
752
+
753
+ def get_parent_folder(self, item: DriveItem) -> Optional[DriveFolder]:
754
+ """
755
+ Get the parent folder of a file or folder.
756
+
757
+ Args:
758
+ item: DriveItem object to get parent for
759
+
760
+ Returns:
761
+ Parent DriveFolder, or None if no parent
762
+
763
+ Raises:
764
+ DriveError: If the API request fails
765
+ """
766
+ parent_id = item.get_parent_folder_id()
767
+ if not parent_id:
768
+ return None
769
+
770
+ try:
771
+ result = self._service.files().get(
772
+ fileId=parent_id,
773
+ fields=DEFAULT_FILE_FIELDS
774
+ ).execute()
775
+
776
+ parent_folder = utils.convert_api_file_to_drive_folder(result)
777
+ return parent_folder
778
+
779
+ except HttpError as e:
780
+ if e.resp.status == 404:
781
+ return None
782
+ else:
783
+ error_msg = f"Failed to get parent folder {parent_id}: {e}"
784
+ raise DriveError(error_msg)
785
+ except Exception as e:
786
+ error_msg = f"Unexpected error getting parent folder {parent_id}: {e}"
787
+ raise DriveError(error_msg)
788
+
789
+ def get_folder_by_path(self, path: str, root_folder_id: str = "root") -> Optional[DriveFolder]:
790
+ """
791
+ Find a folder by its path relative to a root folder.
792
+
793
+ Args:
794
+ path: Folder path like "/Documents/Projects" or "Documents/Projects"
795
+ root_folder_id: ID of the root folder to start from (default: Drive root)
796
+
797
+ Returns:
798
+ DriveFolder object for the folder, or None if not found
799
+
800
+ Raises:
801
+ DriveError: If the API request fails
802
+ """
803
+ from . import utils as drive_utils
804
+
805
+ folder_names = drive_utils.parse_folder_path(path)
806
+ if not folder_names:
807
+ # Return root folder
808
+ try:
809
+ result = self._service.files().get(
810
+ fileId=root_folder_id,
811
+ fields=DEFAULT_FILE_FIELDS
812
+ ).execute()
813
+ return utils.convert_api_file_to_drive_folder(result)
814
+ except Exception:
815
+ return None
816
+
817
+ current_folder_id = root_folder_id
818
+
819
+ try:
820
+ for folder_name in folder_names:
821
+ # Search for folder with this name in current folder
822
+ folders = (self.query()
823
+ .in_folder(current_folder_id)
824
+ .folders_named(folder_name)
825
+ .limit(1)
826
+ .execute())
827
+
828
+ if not folders:
829
+ return None
830
+
831
+ current_folder_id = folders[0].folder_id
832
+
833
+ # Get the final folder
834
+ result = self._service.files().get(
835
+ fileId=current_folder_id,
836
+ fields=DEFAULT_FILE_FIELDS
837
+ ).execute()
838
+
839
+ final_folder = utils.convert_api_file_to_drive_folder(result)
840
+ return final_folder
841
+
842
+ except Exception as e:
843
+ error_msg = f"Failed to get folder by path '{path}': {e}"
844
+ raise DriveError(error_msg)
845
+
846
+ def create_folder_path(
847
+ self,
848
+ path: str,
849
+ root_folder_id: str = "root",
850
+ description: Optional[str] = None
851
+ ) -> DriveFolder:
852
+ """
853
+ Create a nested folder structure from a path, creating missing folders as needed.
854
+
855
+ Args:
856
+ path: Folder path like "/Documents/Projects/MyProject"
857
+ root_folder_id: ID of the root folder to start from
858
+ description: Description for the final folder
859
+
860
+ Returns:
861
+ DriveFolder object for the final folder in the path
862
+
863
+ Raises:
864
+ DriveError: If the API request fails
865
+ """
866
+ from . import utils as drive_utils
867
+
868
+ folder_names = drive_utils.parse_folder_path(path)
869
+ if not folder_names:
870
+ raise ValueError("Invalid folder path")
871
+
872
+ current_folder_id = root_folder_id
873
+
874
+ try:
875
+ for i, folder_name in enumerate(folder_names):
876
+ # Check if folder already exists
877
+ existing_folders = (self.query()
878
+ .in_folder(current_folder_id)
879
+ .folders_named(folder_name)
880
+ .limit(1)
881
+ .execute())
882
+
883
+ if existing_folders:
884
+ current_folder_id = existing_folders[0].item_id
885
+ else:
886
+ # Create the folder - get parent folder object first
887
+ folder_desc = description if i == len(folder_names) - 1 else None
888
+ if current_folder_id == root_folder_id:
889
+ parent_folder = None # Root folder
890
+ else:
891
+ # Get parent folder as DriveFolder
892
+ parent_result = self._service.files().get(
893
+ fileId=current_folder_id,
894
+ fields=DEFAULT_FILE_FIELDS
895
+ ).execute()
896
+ parent_folder = utils.convert_api_file_to_drive_folder(parent_result)
897
+
898
+ new_folder = self.create_folder(
899
+ name=folder_name,
900
+ parent_folder=parent_folder,
901
+ description=folder_desc
902
+ )
903
+ current_folder_id = new_folder.folder_id
904
+
905
+ # Return the final folder
906
+ result = self._service.files().get(
907
+ fileId=current_folder_id,
908
+ fields=DEFAULT_FILE_FIELDS
909
+ ).execute()
910
+
911
+ final_folder = utils.convert_api_file_to_drive_folder(result)
912
+ return final_folder
913
+
914
+ except Exception as e:
915
+ error_msg = f"Failed to create folder path '{path}': {e}"
916
+ raise DriveError(error_msg)
917
+
918
+ def move_to_trash(self, item: DriveItem) -> DriveItem:
919
+ """
920
+ Move a file or folder to trash.
921
+ Args:
922
+ item: DriveItem object to move to trash
923
+ Returns:
924
+ Updated DriveItem object
925
+ Raises:
926
+ FileNotFoundError: If the item is not found
927
+ DriveError: If the API request fails
928
+ """
929
+ try:
930
+ result = self._service.files().update(
931
+ fileId=item.item_id,
932
+ body={'trashed': True},
933
+ fields=DEFAULT_FILE_FIELDS
934
+ ).execute()
935
+
936
+ updated_item = utils.convert_api_file_to_correct_type(result)
937
+ return updated_item
938
+
939
+ except HttpError as e:
940
+ if e.resp.status == 404:
941
+ raise FileNotFoundError(f"Item not found: {item.item_id}")
942
+ else:
943
+ error_msg = f"Failed to move item to trash {item.item_id}: {e}"
944
+ raise DriveError(error_msg)
945
+ except Exception as e:
946
+ error_msg = f"Unexpected error moving item to trash {item.item_id}: {e}"
947
+ raise DriveError(error_msg)
948
+
949
+ def get_directory_tree(
950
+ self,
951
+ folder: DriveFolder = None,
952
+ max_depth: int = 3,
953
+ include_files: bool = True
954
+ ) -> Dict[str, Any]:
955
+ """
956
+ Get directory tree structure as nested dictionary.
957
+
958
+ Args:
959
+ folder: DriveFolder to get tree structure for
960
+ max_depth: Maximum depth to traverse (prevents infinite loops)
961
+ include_files: Whether to include files in the tree
962
+
963
+ Returns:
964
+ Nested dictionary representing the tree structure
965
+
966
+ Raises:
967
+ FolderNotFoundError: If the folder is not found
968
+ DriveError: If the API request fails
969
+ """
970
+
971
+ if not folder:
972
+ folder = self.get('root')
973
+
974
+ def _build_tree_recursive(current_folder: DriveFolder, current_depth: int) -> Dict[str, Any]:
975
+ # Build current node
976
+ node = {
977
+ 'name': current_folder.name,
978
+ 'type': 'folder',
979
+ 'id': current_folder.folder_id,
980
+ 'size': None,
981
+ 'children': []
982
+ }
983
+
984
+ # Stop recursion if max depth reached
985
+ if current_depth >= max_depth:
986
+ return node
987
+
988
+ try:
989
+ # Get folder contents
990
+ contents = self.list_folder_contents(
991
+ current_folder,
992
+ include_folders=True,
993
+ include_files=include_files,
994
+ max_results=1000
995
+ )
996
+
997
+ # Process each item
998
+ for item in contents:
999
+ if isinstance(item, DriveFolder):
1000
+ # Recursively build subtree for folders
1001
+ child_node = _build_tree_recursive(item, current_depth + 1)
1002
+ node['children'].append(child_node)
1003
+ elif isinstance(item, DriveFile) and include_files:
1004
+ # Add file node
1005
+ file_node = {
1006
+ 'name': item.name,
1007
+ 'type': 'file',
1008
+ 'id': item.file_id,
1009
+ 'size': item.size,
1010
+ 'children': None
1011
+ }
1012
+ node['children'].append(file_node)
1013
+
1014
+ except (FolderNotFoundError, PermissionDeniedError) as e:
1015
+ # Handle permission errors gracefully
1016
+ node['children'] = None
1017
+ node['error'] = str(e)
1018
+
1019
+ return node
1020
+
1021
+ try:
1022
+ tree = _build_tree_recursive(folder, 0)
1023
+ return tree
1024
+
1025
+ except Exception as e:
1026
+ error_msg = f"Failed to build directory tree: {e}"
1027
+ raise DriveError(error_msg)
1028
+
1029
+ def print_directory_tree(
1030
+ self,
1031
+ folder: DriveFolder = None,
1032
+ max_depth: int = 3,
1033
+ show_files: bool = True,
1034
+ show_sizes: bool = True,
1035
+ show_dates: bool = False,
1036
+ _current_depth: int = 0,
1037
+ _prefix: str = ""
1038
+ ) -> None:
1039
+ """
1040
+ Print visual tree representation of folder structure.
1041
+
1042
+ Args:
1043
+ folder: DriveFolder to print tree structure for
1044
+ max_depth: Maximum depth to traverse
1045
+ show_files: Whether to include files in the output
1046
+ show_sizes: Whether to show file sizes
1047
+ show_dates: Whether to show modification dates
1048
+ _current_depth: Internal parameter for recursion
1049
+ _prefix: Internal parameter for tree formatting
1050
+
1051
+ Raises:
1052
+ FolderNotFoundError: If the folder is not found
1053
+ DriveError: If the API request fails
1054
+ """
1055
+
1056
+ if not folder:
1057
+ folder = self.get('root')
1058
+
1059
+ # Print current folder
1060
+ if _current_depth == 0:
1061
+ print(f"📁 {folder.name}/")
1062
+
1063
+ # Stop recursion if max depth reached
1064
+ if _current_depth >= max_depth:
1065
+ return
1066
+
1067
+ try:
1068
+ # Get folder contents
1069
+ contents = self.list_folder_contents(
1070
+ folder,
1071
+ include_folders=True,
1072
+ include_files=show_files,
1073
+ max_results=1000,
1074
+ order_by="name"
1075
+ )
1076
+
1077
+ # Sort contents: folders first, then files
1078
+ folders = [item for item in contents if isinstance(item, DriveFolder)]
1079
+ files = [item for item in contents if isinstance(item, DriveFile)]
1080
+ sorted_contents = folders + files
1081
+
1082
+ for i, item in enumerate(sorted_contents):
1083
+ is_last = (i == len(sorted_contents) - 1)
1084
+
1085
+ # Choose tree characters
1086
+ if is_last:
1087
+ current_prefix = _prefix + "└── "
1088
+ next_prefix = _prefix + " "
1089
+ else:
1090
+ current_prefix = _prefix + "├── "
1091
+ next_prefix = _prefix + "│ "
1092
+
1093
+ # Format item display
1094
+ if isinstance(item, DriveFolder):
1095
+ # Folder display
1096
+ display_name = f"📁 {item.name}/"
1097
+ print(current_prefix + display_name)
1098
+
1099
+ # Recursively print subfolder
1100
+ self.print_directory_tree(
1101
+ item,
1102
+ max_depth=max_depth,
1103
+ show_files=show_files,
1104
+ show_sizes=show_sizes,
1105
+ show_dates=show_dates,
1106
+ _current_depth=_current_depth + 1,
1107
+ _prefix=next_prefix
1108
+ )
1109
+
1110
+ elif isinstance(item, DriveFile):
1111
+ # File display
1112
+ display_parts = [f"📄 {item.name}"]
1113
+
1114
+ if show_sizes and item.size is not None:
1115
+ display_parts.append(f"({item.human_readable_size()})")
1116
+
1117
+ if show_dates and item.modified_time:
1118
+ from ...utils.datetime import convert_datetime_to_readable
1119
+ readable_date = convert_datetime_to_readable(item.modified_time)
1120
+ display_parts.append(f"[{readable_date}]")
1121
+
1122
+ display_name = " ".join(display_parts)
1123
+ print(current_prefix + display_name)
1124
+
1125
+ except (FolderNotFoundError, PermissionDeniedError) as e:
1126
+ # Handle permission errors gracefully
1127
+ error_prefix = _prefix + "└── " if _current_depth > 0 else ""
1128
+ print(f"{error_prefix}❌ Access denied: {e}")
1129
+ except Exception as e:
1130
+ error_msg = f"Error displaying folder contents: {e}"
1131
+ if _current_depth == 0:
1132
+ raise DriveError(error_msg)
1133
+