gsuite-sdk 0.1.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.
gsuite_drive/client.py ADDED
@@ -0,0 +1,401 @@
1
+ """Drive client - high-level interface."""
2
+
3
+ import io
4
+ import logging
5
+ from typing import BinaryIO
6
+
7
+ from googleapiclient.discovery import build
8
+ from googleapiclient.errors import HttpError
9
+ from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload, MediaIoBaseUpload
10
+
11
+ from gsuite_core import GoogleAuth
12
+ from gsuite_drive.file import File, Folder
13
+ from gsuite_drive.parser import DriveParser
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class Drive:
19
+ """
20
+ High-level Google Drive client.
21
+
22
+ Example:
23
+ auth = GoogleAuth()
24
+ auth.authenticate()
25
+
26
+ drive = Drive(auth)
27
+
28
+ # List files
29
+ for file in drive.list_files():
30
+ print(f"{file.name} ({file.mime_type})")
31
+
32
+ # Upload
33
+ drive.upload("document.pdf")
34
+
35
+ # Download
36
+ file = drive.get("file_id")
37
+ file.download("local_copy.pdf")
38
+ """
39
+
40
+ def __init__(self, auth: GoogleAuth):
41
+ """
42
+ Initialize Drive client.
43
+
44
+ Args:
45
+ auth: GoogleAuth instance with valid credentials
46
+ """
47
+ self.auth = auth
48
+ self._service = None
49
+
50
+ @property
51
+ def service(self):
52
+ """Lazy-load Drive API service."""
53
+ if self._service is None:
54
+ self._service = build("drive", "v3", credentials=self.auth.credentials)
55
+ return self._service
56
+
57
+ # ========== File listing ==========
58
+
59
+ def list_files(
60
+ self,
61
+ query: str | None = None,
62
+ parent_id: str | None = None,
63
+ mime_type: str | None = None,
64
+ max_results: int = 100,
65
+ order_by: str = "modifiedTime desc",
66
+ ) -> list[File]:
67
+ """
68
+ List files in Drive.
69
+
70
+ Args:
71
+ query: Drive API query string
72
+ parent_id: Filter by parent folder ID
73
+ mime_type: Filter by MIME type
74
+ max_results: Maximum files to return
75
+ order_by: Sort order
76
+
77
+ Returns:
78
+ List of File objects
79
+ """
80
+ # Build query
81
+ query_parts = []
82
+ if query:
83
+ query_parts.append(query)
84
+ if parent_id:
85
+ query_parts.append(f"'{parent_id}' in parents")
86
+ if mime_type:
87
+ query_parts.append(f"mimeType='{mime_type}'")
88
+
89
+ # Don't include trashed files
90
+ query_parts.append("trashed=false")
91
+
92
+ full_query = " and ".join(query_parts)
93
+
94
+ response = (
95
+ self.service.files()
96
+ .list(
97
+ q=full_query,
98
+ pageSize=min(max_results, 1000),
99
+ orderBy=order_by,
100
+ fields="files(id, name, mimeType, size, createdTime, modifiedTime, parents, webViewLink, webContentLink)",
101
+ )
102
+ .execute()
103
+ )
104
+
105
+ files = []
106
+ for item in response.get("files", []):
107
+ file = self._parse_file(item)
108
+ files.append(file)
109
+
110
+ return files
111
+
112
+ def list_folders(self, parent_id: str | None = None) -> list[Folder]:
113
+ """List folders."""
114
+ files = self.list_files(
115
+ parent_id=parent_id,
116
+ mime_type="application/vnd.google-apps.folder",
117
+ )
118
+ return [
119
+ Folder(**{k: v for k, v in f.__dict__.items() if not k.startswith("_")}) for f in files
120
+ ]
121
+
122
+ def search(self, name: str, exact: bool = False) -> list[File]:
123
+ """
124
+ Search files by name.
125
+
126
+ Args:
127
+ name: File name to search
128
+ exact: Exact match vs contains
129
+
130
+ Returns:
131
+ Matching files
132
+ """
133
+ if exact:
134
+ query = f"name='{name}'"
135
+ else:
136
+ query = f"name contains '{name}'"
137
+
138
+ return self.list_files(query=query)
139
+
140
+ # ========== File operations ==========
141
+
142
+ def get(self, file_id: str) -> File | None:
143
+ """Get a file by ID."""
144
+ try:
145
+ item = (
146
+ self.service.files()
147
+ .get(
148
+ fileId=file_id,
149
+ fields="id, name, mimeType, size, createdTime, modifiedTime, parents, webViewLink, webContentLink",
150
+ )
151
+ .execute()
152
+ )
153
+ return self._parse_file(item)
154
+ except HttpError as e:
155
+ if e.resp.status == 404:
156
+ logger.debug(f"File not found: {file_id}")
157
+ return None
158
+ logger.error(f"Error getting file {file_id}: {e}")
159
+ raise
160
+ except Exception as e:
161
+ logger.error(f"Unexpected error getting file {file_id}: {e}")
162
+ return None
163
+
164
+ def get_content(self, file_id: str) -> bytes:
165
+ """Download file content as bytes."""
166
+ request = self.service.files().get_media(fileId=file_id)
167
+ buffer = io.BytesIO()
168
+ downloader = MediaIoBaseDownload(buffer, request)
169
+
170
+ done = False
171
+ while not done:
172
+ _, done = downloader.next_chunk()
173
+
174
+ return buffer.getvalue()
175
+
176
+ def download(self, file_id: str, path: str) -> str:
177
+ """
178
+ Download file to local path.
179
+
180
+ Args:
181
+ file_id: File ID
182
+ path: Local path to save
183
+
184
+ Returns:
185
+ Path where file was saved
186
+ """
187
+ content = self.get_content(file_id)
188
+ with open(path, "wb") as f:
189
+ f.write(content)
190
+ return path
191
+
192
+ # ========== Upload ==========
193
+
194
+ def upload(
195
+ self,
196
+ path: str,
197
+ name: str | None = None,
198
+ parent_id: str | None = None,
199
+ mime_type: str | None = None,
200
+ ) -> File:
201
+ """
202
+ Upload a file.
203
+
204
+ Args:
205
+ path: Local file path
206
+ name: Name in Drive (default: local filename)
207
+ parent_id: Parent folder ID
208
+ mime_type: MIME type (auto-detected if not provided)
209
+
210
+ Returns:
211
+ Created File
212
+ """
213
+ import os
214
+
215
+ file_name = name or os.path.basename(path)
216
+
217
+ metadata = {"name": file_name}
218
+ if parent_id:
219
+ metadata["parents"] = [parent_id]
220
+
221
+ media = MediaFileUpload(path, mimetype=mime_type, resumable=True)
222
+
223
+ created = (
224
+ self.service.files()
225
+ .create(
226
+ body=metadata,
227
+ media_body=media,
228
+ fields="id, name, mimeType, size, createdTime, modifiedTime, parents, webViewLink",
229
+ )
230
+ .execute()
231
+ )
232
+
233
+ return self._parse_file(created)
234
+
235
+ def upload_content(
236
+ self,
237
+ content: bytes | BinaryIO,
238
+ name: str,
239
+ parent_id: str | None = None,
240
+ mime_type: str = "application/octet-stream",
241
+ ) -> File:
242
+ """
243
+ Upload content directly.
244
+
245
+ Args:
246
+ content: File content as bytes or file-like object
247
+ name: Name in Drive
248
+ parent_id: Parent folder ID
249
+ mime_type: MIME type
250
+
251
+ Returns:
252
+ Created File
253
+ """
254
+ if isinstance(content, bytes):
255
+ buffer = io.BytesIO(content)
256
+ else:
257
+ buffer = content
258
+
259
+ metadata = {"name": name}
260
+ if parent_id:
261
+ metadata["parents"] = [parent_id]
262
+
263
+ media = MediaIoBaseUpload(buffer, mimetype=mime_type, resumable=True)
264
+
265
+ created = (
266
+ self.service.files()
267
+ .create(
268
+ body=metadata,
269
+ media_body=media,
270
+ fields="id, name, mimeType, size, createdTime, modifiedTime, parents, webViewLink",
271
+ )
272
+ .execute()
273
+ )
274
+
275
+ return self._parse_file(created)
276
+
277
+ # ========== Folder operations ==========
278
+
279
+ def create_folder(
280
+ self,
281
+ name: str,
282
+ parent_id: str | None = None,
283
+ ) -> Folder:
284
+ """
285
+ Create a folder.
286
+
287
+ Args:
288
+ name: Folder name
289
+ parent_id: Parent folder ID
290
+
291
+ Returns:
292
+ Created Folder
293
+ """
294
+ metadata = {
295
+ "name": name,
296
+ "mimeType": "application/vnd.google-apps.folder",
297
+ }
298
+ if parent_id:
299
+ metadata["parents"] = [parent_id]
300
+
301
+ created = (
302
+ self.service.files()
303
+ .create(
304
+ body=metadata,
305
+ fields="id, name, mimeType, createdTime, modifiedTime, parents, webViewLink",
306
+ )
307
+ .execute()
308
+ )
309
+
310
+ file = self._parse_file(created)
311
+ return Folder(**{k: v for k, v in file.__dict__.items() if not k.startswith("_")})
312
+
313
+ # ========== Delete/Trash ==========
314
+
315
+ def trash(self, file_id: str) -> bool:
316
+ """Move file to trash."""
317
+ try:
318
+ self.service.files().update(
319
+ fileId=file_id,
320
+ body={"trashed": True},
321
+ ).execute()
322
+ logger.info(f"Trashed file {file_id}")
323
+ return True
324
+ except HttpError as e:
325
+ if e.resp.status == 404:
326
+ logger.warning(f"File not found for trash: {file_id}")
327
+ else:
328
+ logger.error(f"Error trashing file {file_id}: {e}")
329
+ return False
330
+ except Exception as e:
331
+ logger.error(f"Unexpected error trashing file {file_id}: {e}")
332
+ return False
333
+
334
+ def delete(self, file_id: str) -> bool:
335
+ """Permanently delete file."""
336
+ try:
337
+ self.service.files().delete(fileId=file_id).execute()
338
+ logger.info(f"Deleted file {file_id}")
339
+ return True
340
+ except HttpError as e:
341
+ if e.resp.status == 404:
342
+ logger.warning(f"File not found for deletion: {file_id}")
343
+ else:
344
+ logger.error(f"Error deleting file {file_id}: {e}")
345
+ return False
346
+ except Exception as e:
347
+ logger.error(f"Unexpected error deleting file {file_id}: {e}")
348
+ return False
349
+
350
+ # ========== Sharing ==========
351
+
352
+ def share(
353
+ self,
354
+ file_id: str,
355
+ email: str,
356
+ role: str = "reader",
357
+ notify: bool = True,
358
+ ) -> bool:
359
+ """
360
+ Share a file with someone.
361
+
362
+ Args:
363
+ file_id: File ID
364
+ email: Email to share with
365
+ role: Permission role (reader, writer, commenter)
366
+ notify: Send notification email
367
+
368
+ Returns:
369
+ True if successful
370
+ """
371
+ try:
372
+ self.service.permissions().create(
373
+ fileId=file_id,
374
+ body={
375
+ "type": "user",
376
+ "role": role,
377
+ "emailAddress": email,
378
+ },
379
+ sendNotificationEmail=notify,
380
+ ).execute()
381
+ logger.info(f"Shared file {file_id} with {email} ({role})")
382
+ return True
383
+ except HttpError as e:
384
+ if e.resp.status == 404:
385
+ logger.error(f"File not found for sharing: {file_id}")
386
+ elif e.resp.status == 400:
387
+ logger.error(f"Invalid share request for {file_id}: {e}")
388
+ else:
389
+ logger.error(f"Error sharing file {file_id}: {e}")
390
+ return False
391
+ except Exception as e:
392
+ logger.error(f"Unexpected error sharing file {file_id}: {e}")
393
+ return False
394
+
395
+ # ========== Parsing ==========
396
+
397
+ def _parse_file(self, data: dict) -> File:
398
+ """Parse Drive API response to File object."""
399
+ file = DriveParser.parse_file(data)
400
+ file._drive = self
401
+ return file
gsuite_drive/file.py ADDED
@@ -0,0 +1,103 @@
1
+ """Drive File and Folder entities."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Optional
6
+
7
+ if TYPE_CHECKING:
8
+ from gsuite_drive.client import Drive
9
+
10
+
11
+ @dataclass
12
+ class File:
13
+ """
14
+ Google Drive file.
15
+
16
+ Represents a file in Google Drive with methods for
17
+ download, update, and management.
18
+ """
19
+
20
+ id: str
21
+ name: str
22
+ mime_type: str
23
+ size: int = 0
24
+ created_time: datetime | None = None
25
+ modified_time: datetime | None = None
26
+ parents: list[str] = field(default_factory=list)
27
+ web_view_link: str | None = None
28
+ web_content_link: str | None = None
29
+
30
+ _drive: Optional["Drive"] = field(default=None, repr=False)
31
+
32
+ @property
33
+ def is_folder(self) -> bool:
34
+ """Check if this is a folder."""
35
+ return self.mime_type == "application/vnd.google-apps.folder"
36
+
37
+ @property
38
+ def is_google_doc(self) -> bool:
39
+ """Check if this is a Google Docs file."""
40
+ return self.mime_type.startswith("application/vnd.google-apps.")
41
+
42
+ def download(self, path: str | None = None) -> str:
43
+ """
44
+ Download file to local path.
45
+
46
+ Args:
47
+ path: Local path (default: current dir with original name)
48
+
49
+ Returns:
50
+ Path where file was saved
51
+ """
52
+ if not self._drive:
53
+ raise RuntimeError("File not linked to Drive client")
54
+ return self._drive.download(self.id, path or self.name)
55
+
56
+ def get_content(self) -> bytes:
57
+ """
58
+ Get file content as bytes.
59
+
60
+ Returns:
61
+ File content
62
+ """
63
+ if not self._drive:
64
+ raise RuntimeError("File not linked to Drive client")
65
+ return self._drive.get_content(self.id)
66
+
67
+ def trash(self) -> "File":
68
+ """Move file to trash."""
69
+ if self._drive:
70
+ self._drive.trash(self.id)
71
+ return self
72
+
73
+ def delete(self) -> None:
74
+ """Permanently delete file."""
75
+ if self._drive:
76
+ self._drive.delete(self.id)
77
+
78
+
79
+ @dataclass
80
+ class Folder(File):
81
+ """
82
+ Google Drive folder.
83
+
84
+ A folder is a special type of file.
85
+ """
86
+
87
+ def __post_init__(self):
88
+ """Ensure mime type is folder."""
89
+ self.mime_type = "application/vnd.google-apps.folder"
90
+
91
+ def list_files(self, recursive: bool = False) -> list[File]:
92
+ """
93
+ List files in this folder.
94
+
95
+ Args:
96
+ recursive: Include files in subfolders
97
+
98
+ Returns:
99
+ List of files
100
+ """
101
+ if not self._drive:
102
+ raise RuntimeError("Folder not linked to Drive client")
103
+ return self._drive.list_files(parent_id=self.id)
gsuite_drive/parser.py ADDED
@@ -0,0 +1,66 @@
1
+ """Drive response parsers - converts API responses to domain entities."""
2
+
3
+ from datetime import datetime
4
+
5
+ from gsuite_drive.file import File, Folder
6
+
7
+
8
+ class DriveParser:
9
+ """Parser for Drive API responses."""
10
+
11
+ @staticmethod
12
+ def parse_file(data: dict) -> File:
13
+ """
14
+ Parse Drive API response to File entity.
15
+
16
+ Args:
17
+ data: Raw API response dict
18
+
19
+ Returns:
20
+ File entity
21
+ """
22
+ return File(
23
+ id=data["id"],
24
+ name=data.get("name", ""),
25
+ mime_type=data.get("mimeType", "application/octet-stream"),
26
+ size=int(data.get("size", 0)),
27
+ created_time=DriveParser._parse_datetime(data.get("createdTime")),
28
+ modified_time=DriveParser._parse_datetime(data.get("modifiedTime")),
29
+ parents=data.get("parents", []),
30
+ web_view_link=data.get("webViewLink"),
31
+ web_content_link=data.get("webContentLink"),
32
+ )
33
+
34
+ @staticmethod
35
+ def parse_folder(data: dict) -> Folder:
36
+ """
37
+ Parse Drive API response to Folder entity.
38
+
39
+ Args:
40
+ data: Raw API response dict
41
+
42
+ Returns:
43
+ Folder entity
44
+ """
45
+ file = DriveParser.parse_file(data)
46
+ return Folder(
47
+ id=file.id,
48
+ name=file.name,
49
+ mime_type=file.mime_type,
50
+ size=file.size,
51
+ created_time=file.created_time,
52
+ modified_time=file.modified_time,
53
+ parents=file.parents,
54
+ web_view_link=file.web_view_link,
55
+ web_content_link=file.web_content_link,
56
+ )
57
+
58
+ @staticmethod
59
+ def _parse_datetime(dt_string: str | None) -> datetime | None:
60
+ """Parse ISO datetime string to datetime object."""
61
+ if not dt_string:
62
+ return None
63
+ try:
64
+ return datetime.fromisoformat(dt_string.replace("Z", "+00:00"))
65
+ except (ValueError, TypeError):
66
+ return None
gsuite_drive/py.typed ADDED
File without changes
@@ -0,0 +1,17 @@
1
+ """Google Suite Gmail - Simple Gmail API client."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from gsuite_gmail import query
6
+ from gsuite_gmail.client import Gmail
7
+ from gsuite_gmail.label import Label
8
+ from gsuite_gmail.message import Message
9
+ from gsuite_gmail.thread import Thread
10
+
11
+ __all__ = [
12
+ "Gmail",
13
+ "Message",
14
+ "Thread",
15
+ "Label",
16
+ "query",
17
+ ]