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.
@@ -0,0 +1,375 @@
1
+ """Sheets client - high-level interface."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from googleapiclient.discovery import build
7
+ from googleapiclient.errors import HttpError
8
+
9
+ from gsuite_core import GoogleAuth
10
+ from gsuite_sheets.parser import SheetsParser
11
+ from gsuite_sheets.spreadsheet import Spreadsheet
12
+ from gsuite_sheets.worksheet import Worksheet
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class Sheets:
18
+ """
19
+ High-level Google Sheets client.
20
+
21
+ Inspired by gspread's simple API.
22
+
23
+ Example:
24
+ auth = GoogleAuth()
25
+ auth.authenticate()
26
+
27
+ sheets = Sheets(auth)
28
+
29
+ # Open by title (like gspread!)
30
+ doc = sheets.open("My Spreadsheet")
31
+
32
+ # Or by key/url
33
+ doc = sheets.open_by_key("abc123...")
34
+ doc = sheets.open_by_url("https://docs.google.com/spreadsheets/...")
35
+
36
+ # Work with worksheets
37
+ ws = doc.sheet1
38
+ data = ws.get_all_values()
39
+ ws.update("A1", [["Hello", "World"]])
40
+ """
41
+
42
+ def __init__(self, auth: GoogleAuth):
43
+ """
44
+ Initialize Sheets client.
45
+
46
+ Args:
47
+ auth: GoogleAuth instance with valid credentials
48
+ """
49
+ self.auth = auth
50
+ self._sheets_service = None
51
+ self._drive_service = None
52
+
53
+ @property
54
+ def service(self):
55
+ """Lazy-load Sheets API service."""
56
+ if self._sheets_service is None:
57
+ self._sheets_service = build("sheets", "v4", credentials=self.auth.credentials)
58
+ return self._sheets_service
59
+
60
+ @property
61
+ def drive(self):
62
+ """Lazy-load Drive API service (for listing/sharing)."""
63
+ if self._drive_service is None:
64
+ self._drive_service = build("drive", "v3", credentials=self.auth.credentials)
65
+ return self._drive_service
66
+
67
+ # ========== Opening spreadsheets (gspread-style) ==========
68
+
69
+ def open(self, title: str) -> Spreadsheet:
70
+ """
71
+ Open a spreadsheet by title.
72
+
73
+ Args:
74
+ title: Spreadsheet title
75
+
76
+ Returns:
77
+ Spreadsheet object
78
+
79
+ Raises:
80
+ ValueError: If not found
81
+ """
82
+ # Search in Drive
83
+ response = (
84
+ self.drive.files()
85
+ .list(
86
+ q=f"name='{title}' and mimeType='application/vnd.google-apps.spreadsheet' and trashed=false",
87
+ fields="files(id, name)",
88
+ pageSize=1,
89
+ )
90
+ .execute()
91
+ )
92
+
93
+ files = response.get("files", [])
94
+ if not files:
95
+ raise ValueError(f"Spreadsheet not found: {title}")
96
+
97
+ return self.open_by_key(files[0]["id"])
98
+
99
+ def open_by_key(self, key: str) -> Spreadsheet:
100
+ """
101
+ Open a spreadsheet by key (ID).
102
+
103
+ Args:
104
+ key: Spreadsheet ID
105
+
106
+ Returns:
107
+ Spreadsheet object
108
+ """
109
+ response = (
110
+ self.service.spreadsheets()
111
+ .get(
112
+ spreadsheetId=key,
113
+ fields="spreadsheetId,properties,sheets.properties",
114
+ )
115
+ .execute()
116
+ )
117
+
118
+ return self._parse_spreadsheet(response)
119
+
120
+ def open_by_url(self, url: str) -> Spreadsheet:
121
+ """
122
+ Open a spreadsheet by URL.
123
+
124
+ Args:
125
+ url: Full Google Sheets URL
126
+
127
+ Returns:
128
+ Spreadsheet object
129
+ """
130
+ # Extract key from URL
131
+ import re
132
+
133
+ match = re.search(r"/spreadsheets/d/([a-zA-Z0-9-_]+)", url)
134
+ if not match:
135
+ raise ValueError(f"Invalid Google Sheets URL: {url}")
136
+
137
+ return self.open_by_key(match.group(1))
138
+
139
+ # ========== Creating spreadsheets ==========
140
+
141
+ def create(self, title: str) -> Spreadsheet:
142
+ """
143
+ Create a new spreadsheet.
144
+
145
+ Args:
146
+ title: Spreadsheet title
147
+
148
+ Returns:
149
+ Created Spreadsheet
150
+ """
151
+ body = {
152
+ "properties": {"title": title},
153
+ "sheets": [{"properties": {"title": "Sheet1"}}],
154
+ }
155
+
156
+ response = self.service.spreadsheets().create(body=body).execute()
157
+ return self._parse_spreadsheet(response)
158
+
159
+ # ========== Listing ==========
160
+
161
+ def list_spreadsheets(self, max_results: int = 100) -> list[dict]:
162
+ """
163
+ List all spreadsheets accessible to the user.
164
+
165
+ Returns:
166
+ List of {id, name} dicts
167
+ """
168
+ response = (
169
+ self.drive.files()
170
+ .list(
171
+ q="mimeType='application/vnd.google-apps.spreadsheet' and trashed=false",
172
+ fields="files(id, name)",
173
+ pageSize=max_results,
174
+ orderBy="modifiedTime desc",
175
+ )
176
+ .execute()
177
+ )
178
+
179
+ return response.get("files", [])
180
+
181
+ # ========== Low-level operations ==========
182
+
183
+ def get_values(self, spreadsheet_id: str, range: str) -> list[list[Any]]:
184
+ """Get values from a range."""
185
+ response = (
186
+ self.service.spreadsheets()
187
+ .values()
188
+ .get(
189
+ spreadsheetId=spreadsheet_id,
190
+ range=range,
191
+ )
192
+ .execute()
193
+ )
194
+ return response.get("values", [])
195
+
196
+ def update_values(
197
+ self,
198
+ spreadsheet_id: str,
199
+ range: str,
200
+ values: list[list[Any]],
201
+ ) -> dict:
202
+ """Update values in a range."""
203
+ return (
204
+ self.service.spreadsheets()
205
+ .values()
206
+ .update(
207
+ spreadsheetId=spreadsheet_id,
208
+ range=range,
209
+ valueInputOption="USER_ENTERED",
210
+ body={"values": values},
211
+ )
212
+ .execute()
213
+ )
214
+
215
+ def append_values(
216
+ self,
217
+ spreadsheet_id: str,
218
+ range: str,
219
+ values: list[list[Any]],
220
+ ) -> dict:
221
+ """Append values to a range."""
222
+ return (
223
+ self.service.spreadsheets()
224
+ .values()
225
+ .append(
226
+ spreadsheetId=spreadsheet_id,
227
+ range=range,
228
+ valueInputOption="USER_ENTERED",
229
+ insertDataOption="INSERT_ROWS",
230
+ body={"values": values},
231
+ )
232
+ .execute()
233
+ )
234
+
235
+ def clear_values(self, spreadsheet_id: str, range: str) -> dict:
236
+ """Clear values from a range."""
237
+ return (
238
+ self.service.spreadsheets()
239
+ .values()
240
+ .clear(
241
+ spreadsheetId=spreadsheet_id,
242
+ range=range,
243
+ )
244
+ .execute()
245
+ )
246
+
247
+ def batch_update(
248
+ self,
249
+ spreadsheet_id: str,
250
+ data: list[dict],
251
+ ) -> dict:
252
+ """
253
+ Batch update multiple ranges.
254
+
255
+ Args:
256
+ spreadsheet_id: Spreadsheet ID
257
+ data: List of {range, values} dicts
258
+ """
259
+ return (
260
+ self.service.spreadsheets()
261
+ .values()
262
+ .batchUpdate(
263
+ spreadsheetId=spreadsheet_id,
264
+ body={
265
+ "valueInputOption": "USER_ENTERED",
266
+ "data": [{"range": d["range"], "values": d["values"]} for d in data],
267
+ },
268
+ )
269
+ .execute()
270
+ )
271
+
272
+ # ========== Worksheet operations ==========
273
+
274
+ def add_worksheet(
275
+ self,
276
+ spreadsheet_id: str,
277
+ title: str,
278
+ rows: int = 1000,
279
+ cols: int = 26,
280
+ ) -> Worksheet:
281
+ """Add a worksheet to a spreadsheet."""
282
+ response = (
283
+ self.service.spreadsheets()
284
+ .batchUpdate(
285
+ spreadsheetId=spreadsheet_id,
286
+ body={
287
+ "requests": [
288
+ {
289
+ "addSheet": {
290
+ "properties": {
291
+ "title": title,
292
+ "gridProperties": {
293
+ "rowCount": rows,
294
+ "columnCount": cols,
295
+ },
296
+ },
297
+ },
298
+ }
299
+ ],
300
+ },
301
+ )
302
+ .execute()
303
+ )
304
+
305
+ return SheetsParser.parse_worksheet_from_reply(response["replies"][0]["addSheet"])
306
+
307
+ def delete_worksheet(self, spreadsheet_id: str, sheet_id: int) -> bool:
308
+ """Delete a worksheet."""
309
+ try:
310
+ self.service.spreadsheets().batchUpdate(
311
+ spreadsheetId=spreadsheet_id,
312
+ body={
313
+ "requests": [
314
+ {
315
+ "deleteSheet": {"sheetId": sheet_id},
316
+ }
317
+ ],
318
+ },
319
+ ).execute()
320
+ logger.info(f"Deleted worksheet {sheet_id} from {spreadsheet_id}")
321
+ return True
322
+ except HttpError as e:
323
+ if e.resp.status == 400:
324
+ logger.error(f"Cannot delete worksheet {sheet_id}: {e}")
325
+ else:
326
+ logger.error(f"Error deleting worksheet {sheet_id}: {e}")
327
+ return False
328
+ except Exception as e:
329
+ logger.error(f"Unexpected error deleting worksheet {sheet_id}: {e}")
330
+ return False
331
+
332
+ # ========== Sharing ==========
333
+
334
+ def share(
335
+ self,
336
+ spreadsheet_id: str,
337
+ email: str,
338
+ role: str = "reader",
339
+ notify: bool = True,
340
+ ) -> bool:
341
+ """Share a spreadsheet."""
342
+ try:
343
+ self.drive.permissions().create(
344
+ fileId=spreadsheet_id,
345
+ body={
346
+ "type": "user",
347
+ "role": role,
348
+ "emailAddress": email,
349
+ },
350
+ sendNotificationEmail=notify,
351
+ ).execute()
352
+ logger.info(f"Shared spreadsheet {spreadsheet_id} with {email} ({role})")
353
+ return True
354
+ except HttpError as e:
355
+ if e.resp.status == 404:
356
+ logger.error(f"Spreadsheet not found: {spreadsheet_id}")
357
+ else:
358
+ logger.error(f"Error sharing spreadsheet {spreadsheet_id}: {e}")
359
+ return False
360
+ except Exception as e:
361
+ logger.error(f"Unexpected error sharing spreadsheet: {e}")
362
+ return False
363
+
364
+ # ========== Parsing ==========
365
+
366
+ def _parse_spreadsheet(self, data: dict) -> Spreadsheet:
367
+ """Parse API response to Spreadsheet object."""
368
+ spreadsheet = SheetsParser.parse_spreadsheet(data)
369
+
370
+ # Link worksheets to spreadsheet and client
371
+ for ws in spreadsheet.worksheets:
372
+ ws._spreadsheet = spreadsheet
373
+
374
+ spreadsheet._sheets = self
375
+ return spreadsheet
@@ -0,0 +1,76 @@
1
+ """Sheets response parsers - converts API responses to domain entities."""
2
+
3
+ from gsuite_sheets.spreadsheet import Spreadsheet
4
+ from gsuite_sheets.worksheet import Worksheet
5
+
6
+
7
+ class SheetsParser:
8
+ """Parser for Sheets API responses."""
9
+
10
+ @staticmethod
11
+ def parse_spreadsheet(data: dict) -> Spreadsheet:
12
+ """
13
+ Parse Sheets API response to Spreadsheet entity.
14
+
15
+ Args:
16
+ data: Raw API response dict
17
+
18
+ Returns:
19
+ Spreadsheet entity (without client reference)
20
+ """
21
+ props = data.get("properties", {})
22
+
23
+ worksheets = [SheetsParser.parse_worksheet(sheet) for sheet in data.get("sheets", [])]
24
+
25
+ return Spreadsheet(
26
+ id=data["spreadsheetId"],
27
+ title=props.get("title", ""),
28
+ url=f"https://docs.google.com/spreadsheets/d/{data['spreadsheetId']}",
29
+ locale=props.get("locale", "en_US"),
30
+ time_zone=props.get("timeZone", "America/New_York"),
31
+ worksheets=worksheets,
32
+ )
33
+
34
+ @staticmethod
35
+ def parse_worksheet(data: dict) -> Worksheet:
36
+ """
37
+ Parse sheet data to Worksheet entity.
38
+
39
+ Args:
40
+ data: Raw sheet dict from API (with "properties" key)
41
+
42
+ Returns:
43
+ Worksheet entity
44
+ """
45
+ props = data.get("properties", {})
46
+ grid_props = props.get("gridProperties", {})
47
+
48
+ return Worksheet(
49
+ id=props.get("sheetId", 0),
50
+ title=props.get("title", ""),
51
+ index=props.get("index", 0),
52
+ row_count=grid_props.get("rowCount", 1000),
53
+ column_count=grid_props.get("columnCount", 26),
54
+ )
55
+
56
+ @staticmethod
57
+ def parse_worksheet_from_reply(data: dict) -> Worksheet:
58
+ """
59
+ Parse worksheet from batchUpdate reply.
60
+
61
+ Args:
62
+ data: Reply dict with "addSheet" > "properties"
63
+
64
+ Returns:
65
+ Worksheet entity
66
+ """
67
+ props = data["properties"]
68
+ grid_props = props.get("gridProperties", {})
69
+
70
+ return Worksheet(
71
+ id=props["sheetId"],
72
+ title=props["title"],
73
+ index=props["index"],
74
+ row_count=grid_props.get("rowCount", 1000),
75
+ column_count=grid_props.get("columnCount", 26),
76
+ )
gsuite_sheets/py.typed ADDED
File without changes
@@ -0,0 +1,97 @@
1
+ """Spreadsheet entity."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Optional
5
+
6
+ if TYPE_CHECKING:
7
+ from gsuite_sheets.client import Sheets
8
+
9
+ from gsuite_sheets.worksheet import Worksheet
10
+
11
+
12
+ @dataclass
13
+ class Spreadsheet:
14
+ """
15
+ A Google Spreadsheet.
16
+
17
+ Contains multiple worksheets (tabs).
18
+ """
19
+
20
+ id: str
21
+ title: str
22
+ url: str
23
+ locale: str = "en_US"
24
+ time_zone: str = "America/New_York"
25
+ worksheets: list[Worksheet] = field(default_factory=list)
26
+
27
+ _sheets: Optional["Sheets"] = field(default=None, repr=False)
28
+
29
+ @property
30
+ def sheet1(self) -> Worksheet | None:
31
+ """Get the first worksheet (convenience property like gspread)."""
32
+ return self.worksheets[0] if self.worksheets else None
33
+
34
+ def worksheet(self, title: str) -> Worksheet | None:
35
+ """Get worksheet by title."""
36
+ for ws in self.worksheets:
37
+ if ws.title == title:
38
+ return ws
39
+ return None
40
+
41
+ def get_worksheet(self, index: int) -> Worksheet | None:
42
+ """Get worksheet by index (0-indexed)."""
43
+ if 0 <= index < len(self.worksheets):
44
+ return self.worksheets[index]
45
+ return None
46
+
47
+ def add_worksheet(
48
+ self,
49
+ title: str,
50
+ rows: int = 1000,
51
+ cols: int = 26,
52
+ ) -> Worksheet:
53
+ """
54
+ Add a new worksheet.
55
+
56
+ Args:
57
+ title: Worksheet title
58
+ rows: Number of rows
59
+ cols: Number of columns
60
+
61
+ Returns:
62
+ Created Worksheet
63
+ """
64
+ if not self._sheets:
65
+ raise RuntimeError("Spreadsheet not linked to Sheets client")
66
+
67
+ ws = self._sheets.add_worksheet(self.id, title, rows, cols)
68
+ self.worksheets.append(ws)
69
+ return ws
70
+
71
+ def del_worksheet(self, worksheet: Worksheet) -> bool:
72
+ """Delete a worksheet."""
73
+ if not self._sheets:
74
+ raise RuntimeError("Spreadsheet not linked to Sheets client")
75
+
76
+ success = self._sheets.delete_worksheet(self.id, worksheet.id)
77
+ if success:
78
+ self.worksheets = [ws for ws in self.worksheets if ws.id != worksheet.id]
79
+ return success
80
+
81
+ def share(
82
+ self,
83
+ email: str,
84
+ role: str = "reader",
85
+ notify: bool = True,
86
+ ) -> bool:
87
+ """
88
+ Share spreadsheet with someone.
89
+
90
+ Args:
91
+ email: Email to share with
92
+ role: Permission role (reader, writer)
93
+ notify: Send notification email
94
+ """
95
+ if not self._sheets:
96
+ raise RuntimeError("Spreadsheet not linked to Sheets client")
97
+ return self._sheets.share(self.id, email, role, notify)