google_sheet_writer 0.0.8__tar.gz

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,19 @@
1
+ Copyright (c) 2023 Alexander Pecheny
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: google_sheet_writer
3
+ Version: 0.0.8
4
+ Summary: Easy wrapper around gspread and gspread-formatting
5
+ Home-page: https://gitlab.com/peczony/google_sheet_writer
6
+ Author: Alexander Pecheny
7
+ Author-email: ap@pecheny.me
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE.md
13
+ Requires-Dist: gspread
14
+ Requires-Dist: gspread-formatting
15
+ Dynamic: author
16
+ Dynamic: author-email
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license-file
22
+ Dynamic: requires-dist
23
+ Dynamic: summary
24
+
25
+ # google_sheet_writer
26
+
27
+ **google_sheet_writer** is an object-oriented wrapper around (amazing!) [gspread](https://github.com/burnash/gspread) and [gspread_formatting](https://github.com/robin900/gspread-formatting) that allows to programmatically create Google Sheets tables in an easy way.
28
+
29
+ On top of being convenient, it strives to minimize the amount of requests to save API quota.
30
+
31
+ Install in development mode: `pip install -e .`
32
+
33
+ See examples in `examples` folder.
34
+
35
+ The examples assume `United Kingdom` spreadsheet locale (can be changed in `File → Settings` **prior to launching generation script**). Other locales (e.g. `Russia`) might not work.
36
+
37
+
38
+ ## Version history
39
+
40
+ ### v0.0.8
41
+
42
+ - Batch API calls for dramatically faster spreadsheet generation (especially 10+ sheets)
43
+ - Worksheet creation/deletion batched into a single API call instead of one per sheet
44
+ - Cell values for all sheets written in one `values_batch_update` call
45
+ - Sheet resizing via `updateSheetProperties` instead of writing empty cells
46
+ - Formatting and structural requests merged into a single `batch_update` call
47
+ - Worksheet hiding folded into the formatting batch
48
+
49
+ ### v0.0.7
50
+
51
+ - After successfully finishing table generation, a link to the table is printed to the console
52
+
53
+ ### v0.0.6
54
+
55
+ - Raise exception if users tries to set cursor's x/y attributes to floats
56
+
57
+ ### v0.0.5
58
+
59
+ - Added support for older pythons (3.9 and newer)
@@ -0,0 +1,35 @@
1
+ # google_sheet_writer
2
+
3
+ **google_sheet_writer** is an object-oriented wrapper around (amazing!) [gspread](https://github.com/burnash/gspread) and [gspread_formatting](https://github.com/robin900/gspread-formatting) that allows to programmatically create Google Sheets tables in an easy way.
4
+
5
+ On top of being convenient, it strives to minimize the amount of requests to save API quota.
6
+
7
+ Install in development mode: `pip install -e .`
8
+
9
+ See examples in `examples` folder.
10
+
11
+ The examples assume `United Kingdom` spreadsheet locale (can be changed in `File → Settings` **prior to launching generation script**). Other locales (e.g. `Russia`) might not work.
12
+
13
+
14
+ ## Version history
15
+
16
+ ### v0.0.8
17
+
18
+ - Batch API calls for dramatically faster spreadsheet generation (especially 10+ sheets)
19
+ - Worksheet creation/deletion batched into a single API call instead of one per sheet
20
+ - Cell values for all sheets written in one `values_batch_update` call
21
+ - Sheet resizing via `updateSheetProperties` instead of writing empty cells
22
+ - Formatting and structural requests merged into a single `batch_update` call
23
+ - Worksheet hiding folded into the formatting batch
24
+
25
+ ### v0.0.7
26
+
27
+ - After successfully finishing table generation, a link to the table is printed to the console
28
+
29
+ ### v0.0.6
30
+
31
+ - Raise exception if users tries to set cursor's x/y attributes to floats
32
+
33
+ ### v0.0.5
34
+
35
+ - Added support for older pythons (3.9 and newer)
@@ -0,0 +1,23 @@
1
+ from .classes import (
2
+ Checkbox,
3
+ Cf,
4
+ Cursor,
5
+ GoogleSheetWriter,
6
+ FCell,
7
+ a1_range_to_list_of_cells,
8
+ col_to_a,
9
+ make_address,
10
+ color_from_hex,
11
+ )
12
+
13
+ __all__ = [
14
+ "Checkbox",
15
+ "Cf",
16
+ "Cursor",
17
+ "GoogleSheetWriter",
18
+ "FCell",
19
+ "a1_range_to_list_of_cells",
20
+ "col_to_a",
21
+ "make_address",
22
+ "color_from_hex",
23
+ ]
@@ -0,0 +1,918 @@
1
+ import time
2
+ from collections import defaultdict
3
+ from typing import Callable, NamedTuple, Optional, TypeVar
4
+
5
+ from gspread.cell import Cell
6
+ from gspread.utils import (
7
+ a1_range_to_grid_range,
8
+ absolute_range_name,
9
+ rowcol_to_a1,
10
+ )
11
+ from gspread_formatting import (
12
+ BooleanCondition,
13
+ CellFormat,
14
+ Color,
15
+ ColorStyle,
16
+ DataValidationRule,
17
+ TextFormat,
18
+ batch_updater,
19
+ get_conditional_format_rules,
20
+ )
21
+
22
+ WORKAROUND = "___workaround"
23
+ BATCH_UPDATE_CHUNK_SIZE = 500
24
+ CURSOR_FUNCTIONS = (
25
+ "add_block",
26
+ "hblock",
27
+ "hblock_n",
28
+ "vblock",
29
+ "vblock_n",
30
+ "shift",
31
+ "clone",
32
+ "clone_shift",
33
+ )
34
+
35
+
36
+ class Cf:
37
+ LEFT = CellFormat(horizontalAlignment="LEFT")
38
+ CENTER = CellFormat(horizontalAlignment="CENTER")
39
+ RIGHT = CellFormat(horizontalAlignment="RIGHT")
40
+ TOP = CellFormat(verticalAlignment="TOP")
41
+ MIDDLE = CellFormat(verticalAlignment="MIDDLE")
42
+ BOTTOM = CellFormat(verticalAlignment="BOTTOM")
43
+ BOLD = CellFormat(textFormat=TextFormat(bold=True))
44
+ ITALIC = CellFormat(textFormat=TextFormat(italic=True))
45
+ WRAP = CellFormat(wrapStrategy="WRAP")
46
+ CHECKBOX = DataValidationRule(
47
+ BooleanCondition("BOOLEAN", ["TRUE", "FALSE"]),
48
+ showCustomUi=True,
49
+ )
50
+
51
+ def font_size(n: int):
52
+ return CellFormat(textFormat=TextFormat(fontSize=n))
53
+
54
+ def font_family(name: str):
55
+ return CellFormat(textFormat=TextFormat(fontFamily=name))
56
+
57
+ def text_color(color: Color):
58
+ return CellFormat(
59
+ textFormat=TextFormat(foregroundColorStyle=ColorStyle(rgbColor=color))
60
+ )
61
+
62
+ def background_color(color: Color):
63
+ return CellFormat(backgroundColor=color)
64
+
65
+
66
+ def color_from_hex(hex):
67
+ r = int(hex[0:2], 16)
68
+ g = int(hex[2:4], 16)
69
+ b = int(hex[4:6], 16)
70
+ return Color(r / 255, g / 255, b / 255)
71
+
72
+
73
+ def a1_range_to_list_of_cells(a1_range, orientation="horizontal"):
74
+ result = []
75
+ grid_range = a1_range_to_grid_range(a1_range)
76
+ start_x = grid_range["startColumnIndex"] + 1
77
+ end_x_not_inclusive = grid_range["endColumnIndex"] + 1
78
+ start_y = grid_range["startRowIndex"] + 1
79
+ end_y_not_inclusive = grid_range["endRowIndex"] + 1
80
+ if orientation == "horizontal":
81
+ moving_y = start_y
82
+ while moving_y < end_y_not_inclusive:
83
+ line = []
84
+ moving_x = start_x
85
+ while moving_x < end_x_not_inclusive:
86
+ line.append(Cell(moving_y, moving_x))
87
+ moving_x += 1
88
+ moving_y += 1
89
+ result.append(line)
90
+ elif orientation == "vertical":
91
+ moving_x = start_x
92
+ while moving_x < end_x_not_inclusive:
93
+ line = []
94
+ moving_y = start_y
95
+ while moving_y < end_y_not_inclusive:
96
+ line.append(Cell(moving_y, moving_x))
97
+ moving_y += 1
98
+ moving_x += 1
99
+ result.append(line)
100
+ else:
101
+ raise Exception(
102
+ f"orientation must be horizontal or vertical, was: {orientation}"
103
+ )
104
+ return result
105
+
106
+
107
+ def make_address(worksheet, cell):
108
+ return f"'{worksheet.ws.title}'!{cell_to_a1(cell)}"
109
+
110
+
111
+ class Checkbox:
112
+ def __init__(self, val=False):
113
+ self.val = val
114
+
115
+ def __repr__(self):
116
+ return f"Checkbox({self.val})"
117
+
118
+
119
+ class BlockCoords(NamedTuple):
120
+ init_x: int
121
+ init_y: int
122
+ max_x: int
123
+ max_y: int
124
+ min_x: int
125
+ min_y: int
126
+ end_x: int
127
+ end_y: int
128
+
129
+
130
+ CursorType = TypeVar("T", bound="Cursor")
131
+
132
+
133
+ class Cursor:
134
+ def __init__(self, wks, x=1, y=1):
135
+ self.wks = wks
136
+ if not isinstance(x, int) or not isinstance(y, int):
137
+ raise ValueError("x and y must be integers")
138
+ self.x = x
139
+ self.y = y
140
+
141
+ def clone(self):
142
+ return Cursor(self.wks, self.x, self.y)
143
+
144
+ def set_value(self, cell, val):
145
+ if isinstance(val, FCell):
146
+ rc = rowcol_to_a1(cell.row, cell.col)
147
+ if val.merge_shift_y:
148
+ if val.fmt is None:
149
+ val.fmt = Cf.MIDDLE
150
+ else:
151
+ val.fmt += Cf.MIDDLE
152
+ if val.fmt:
153
+ self.wks.set_cell_format(f"{rc}:{rc}", val.fmt)
154
+ if val.hidden:
155
+ self.hide_col()
156
+ if val.hidden_row:
157
+ self.hide_row()
158
+ if val.align:
159
+ self.align_column(val.align)
160
+ if val.width:
161
+ self.set_col_width(val.width)
162
+ if val.height:
163
+ self.set_row_height(val.height)
164
+ if val.merge_shift_x or val.merge_shift_y:
165
+ merge_shift_x = val.merge_shift_x or 0
166
+ merge_shift_y = val.merge_shift_y or 0
167
+ shifted = self.clone_shift(x=merge_shift_x, y=merge_shift_y)
168
+ self.merge_until(shifted)
169
+ if val.dv_rule:
170
+ rc = rowcol_to_a1(cell.row, cell.col)
171
+ if isinstance(val.dv_rule, Callable):
172
+ dv_rule = val.dv_rule(self)
173
+ else:
174
+ dv_rule = val.dv_rule
175
+ self.wks.parent.fmt[self.wks.ws.title]["dv_cell_ranges"].append(
176
+ (f"{rc}:{rc}", dv_rule)
177
+ )
178
+ val = val.cell
179
+ if isinstance(val, Checkbox):
180
+ rc = rowcol_to_a1(cell.row, cell.col)
181
+ self.wks.parent.fmt[self.wks.ws.title]["dv_cell_ranges"].append(
182
+ (f"{rc}:{rc}", Cf.CHECKBOX)
183
+ )
184
+ cell.value = "TRUE" if val.val else "FALSE"
185
+ elif val is None:
186
+ pass
187
+ elif isinstance(val, Callable):
188
+ cell.value = val(self)
189
+ else:
190
+ cell.value = val
191
+
192
+ def set(
193
+ self,
194
+ cursor: Optional[CursorType] = None,
195
+ x: Optional[int] = None,
196
+ y: Optional[int] = None,
197
+ ):
198
+ if cursor:
199
+ assert x is None
200
+ assert y is None
201
+ assert self.wks == cursor.wks
202
+ self.x = cursor.x
203
+ self.y = cursor.y
204
+ else:
205
+ if x is not None:
206
+ self.x = x
207
+ if y is not None:
208
+ self.y = y
209
+ return self
210
+
211
+ def shift(self, x: int = 0, y: int = 0):
212
+ if not isinstance(x, int) or not isinstance(y, int):
213
+ raise ValueError("x and y must be integers")
214
+ self.x += x
215
+ self.y += y
216
+ return self
217
+
218
+ def clone_shift(self, x: int = 0, y: int = 0):
219
+ if not isinstance(x, int) or not isinstance(y, int):
220
+ raise ValueError("x and y must be integers")
221
+ return Cursor(self.wks, self.x + x, self.y + y)
222
+
223
+ def replace(self, x: Optional[int] = None, y: Optional[int] = None):
224
+ if (x is not None and not isinstance(x, int)) or (
225
+ y is not None and not isinstance(y, int)
226
+ ):
227
+ raise ValueError("x and y must be integers")
228
+ new_x = self.x if x is None else x
229
+ new_y = self.y if y is None else y
230
+ return Cursor(self.wks, new_x, new_y)
231
+
232
+ def as_cell(self, fixed_row=False, fixed_col=False, full=False):
233
+ col = col_to_a(self.x)
234
+ row = str(self.y)
235
+ if fixed_row:
236
+ row = "$" + row
237
+ if fixed_col:
238
+ col = "$" + col
239
+ formatted = col + row
240
+ if full:
241
+ formatted = f"'{self.wks.ws.title}'!{formatted}"
242
+ return formatted
243
+
244
+ def as_cell_full(self, **kwargs):
245
+ kwargs["full"] = True
246
+ return self.as_cell(**kwargs)
247
+
248
+ def __repr__(self):
249
+ return f"Cursor({self.wks.ws.title} {self.x}, {self.y})"
250
+
251
+ def __format__(self, spec):
252
+ full = "f" in spec
253
+ if "p" in spec:
254
+ if "pr" in spec:
255
+ fixed_row = True
256
+ fixed_col = False
257
+ elif "pc" in spec:
258
+ fixed_row = False
259
+ fixed_col = True
260
+ else:
261
+ fixed_row = True
262
+ fixed_col = True
263
+ else:
264
+ fixed_row = False
265
+ fixed_col = False
266
+ return self.as_cell(full=full, fixed_col=fixed_col, fixed_row=fixed_row)
267
+
268
+ def set_col_width(self, width):
269
+ self.wks.parent.fmt[self.wks.ws.title]["column_widths"][self.x] = width
270
+
271
+ def set_row_height(self, height):
272
+ self.wks.parent.fmt[self.wks.ws.title]["row_heights"][self.y] = height
273
+
274
+ def freeze_col(self):
275
+ self.wks.parent.fmt[self.wks.ws.title]["freeze_col"] = self.x
276
+
277
+ def freeze_row(self):
278
+ self.wks.parent.fmt[self.wks.ws.title]["freeze_row"] = self.y
279
+
280
+ def align_column(self, alignment="CENTER"):
281
+ cf = CellFormat(horizontalAlignment=alignment)
282
+ self.wks.parent.fmt[self.wks.ws.title]["cell_ranges"].append(
283
+ (col_to_a(self.x), cf)
284
+ )
285
+
286
+ def hide_col(self):
287
+ self.wks.parent.fmt[self.wks.ws.title]["hidden_cols"].add(self.x)
288
+
289
+ def hide_row(self):
290
+ self.wks.parent.fmt[self.wks.ws.title]["hidden_rows"].add(self.y)
291
+
292
+ def merge_until(self, cell):
293
+ self.wks.parent.fmt[self.wks.ws.title]["merged_cells"].append(
294
+ (self.x, cell.x, self.y, cell.y)
295
+ )
296
+
297
+ def hblock(self, *args, **kwargs):
298
+ kwargs["horizontal"] = True
299
+ kwargs["add_newline"] = False
300
+ return self.add_block(*args, **kwargs)
301
+
302
+ def hblock_n(self, *args, **kwargs):
303
+ kwargs["horizontal"] = True
304
+ kwargs["add_newline"] = True
305
+ return self.add_block(*args, **kwargs)
306
+
307
+ def vblock(self, *args, **kwargs):
308
+ kwargs["horizontal"] = False
309
+ kwargs["add_newline"] = False
310
+ return self.add_block(*args, **kwargs)
311
+
312
+ def vblock_n(self, *args, **kwargs):
313
+ kwargs["horizontal"] = False
314
+ kwargs["add_newline"] = True
315
+ return self.add_block(*args, **kwargs)
316
+
317
+ def add_block(
318
+ self,
319
+ data=None,
320
+ horizontal=False,
321
+ add_newline=False,
322
+ ):
323
+ init_x = self.x
324
+ init_y = self.y
325
+ assert data is not None
326
+ new_cells = []
327
+ for val in data:
328
+ cell = Cell(self.y, self.x)
329
+ self.set_value(cell, val)
330
+ if horizontal:
331
+ self.x += 1
332
+ else:
333
+ self.y += 1
334
+ new_cells.append(cell)
335
+ max_x = max(cell.col for cell in new_cells)
336
+ max_y = max(cell.row for cell in new_cells)
337
+ min_x = min(cell.col for cell in new_cells)
338
+ min_y = min(cell.row for cell in new_cells)
339
+ self.wks.parent.cells[self.wks.ws.title].extend(new_cells)
340
+ if add_newline:
341
+ self.x = init_x
342
+ self.y = max_y + 1
343
+ else:
344
+ self.x = max_x + 1
345
+ self.y = init_y
346
+ return BlockCoords(
347
+ init_x=init_x,
348
+ init_y=init_y,
349
+ max_x=max_x,
350
+ max_y=max_y,
351
+ min_x=min_x,
352
+ min_y=min_y,
353
+ end_x=self.x,
354
+ end_y=self.y,
355
+ )
356
+
357
+
358
+ def cell_to_a1(cell):
359
+ if isinstance(cell, Cursor):
360
+ return cell.as_cell()
361
+ return rowcol_to_a1(cell.row, cell.col)
362
+
363
+
364
+ class FCell:
365
+ def __init__(
366
+ self,
367
+ cell,
368
+ fmt=None,
369
+ width=None,
370
+ height=None,
371
+ hidden=False,
372
+ hidden_row=False,
373
+ align=None,
374
+ dv_rule=None,
375
+ merge_shift_y=None,
376
+ merge_shift_x=None,
377
+ ):
378
+ self.cell = cell
379
+ self.fmt = fmt
380
+ self.width = width
381
+ self.height = height
382
+ self.hidden = hidden
383
+ self.hidden_row = hidden_row
384
+ self.align = align
385
+ self.dv_rule = dv_rule
386
+ self.merge_shift_y = merge_shift_y
387
+ self.merge_shift_x = merge_shift_x
388
+
389
+
390
+ class Worksheet:
391
+ def __init__(
392
+ self,
393
+ ws,
394
+ parent,
395
+ defer=False,
396
+ hidden=False,
397
+ init_format=None,
398
+ force_max_col=1,
399
+ force_max_row=1,
400
+ ):
401
+ self.ws = ws
402
+ self.parent = parent
403
+ self.cursor = Cursor(self)
404
+ self.defer = defer
405
+ self.hidden = hidden
406
+ self.init_format = init_format
407
+ self.force_max_col = force_max_col
408
+ self.force_max_row = force_max_row
409
+ for func in CURSOR_FUNCTIONS:
410
+
411
+ def func_gen(name):
412
+ def _func(*args, **kwargs):
413
+ return getattr(self.cursor, name)(*args, **kwargs)
414
+
415
+ return _func
416
+
417
+ setattr(self, func, func_gen(func))
418
+
419
+ def hide_gridlines(self):
420
+ self.parent.fmt[self.ws.title]["hide_gridlines"] = True
421
+
422
+ def spawn_cursor(self, x=1, y=1):
423
+ if not isinstance(x, int) or not isinstance(y, int):
424
+ raise ValueError("x and y must be integers")
425
+ return Cursor(self, x=x, y=y)
426
+
427
+ def add_to_conditional_formatting(self, rules):
428
+ self.parent.fmt[self.ws.title]["conditional_format_rules"].extend(rules)
429
+
430
+ def set_conditional_formatting(self, rules):
431
+ self.parent.fmt[self.ws.title]["conditional_format_rules"] = rules
432
+
433
+ def set_cell_format(self, range_, cell_format):
434
+ self.parent.fmt[self.ws.title]["cell_ranges"].insert(0, (range_, cell_format))
435
+
436
+ def add_to_batch_clear(self, rng):
437
+ self.parent.batch_clear_ranges.append(absolute_range_name(self.ws.title, rng))
438
+
439
+ def max_col(self):
440
+ return max(c.col for c in self.parent.cells[self.ws.title])
441
+
442
+ def max_row(self):
443
+ return max(c.row for c in self.parent.cells[self.ws.title])
444
+
445
+
446
+ def new_fmt():
447
+ return {
448
+ "conditional_format_rules": [],
449
+ "cell_ranges": [],
450
+ "merged_cells": [],
451
+ "dv_cell_ranges": [],
452
+ "hide_gridlines": False,
453
+ "column_widths": {},
454
+ "row_heights": {},
455
+ "freeze_col": 0,
456
+ "freeze_row": 0,
457
+ "hidden_cols": set(),
458
+ "hidden_rows": set(),
459
+ }
460
+
461
+
462
+ def hide_req(worksheet, id_, rows=False):
463
+ return {
464
+ "updateDimensionProperties": {
465
+ "range": {
466
+ "sheetId": worksheet.ws.id,
467
+ "dimension": "ROWS" if rows else "COLUMNS",
468
+ "startIndex": id_ - 1,
469
+ "endIndex": id_,
470
+ },
471
+ "properties": {
472
+ "hiddenByUser": True,
473
+ },
474
+ "fields": "hiddenByUser",
475
+ }
476
+ }
477
+
478
+
479
+ def delete_req(worksheet, id_min, id_max, rows=False):
480
+ return {
481
+ "deleteDimension": {
482
+ "range": {
483
+ "sheetId": worksheet.ws.id,
484
+ "dimension": "ROWS" if rows else "COLUMNS",
485
+ "startIndex": id_min,
486
+ "endIndex": id_max,
487
+ },
488
+ }
489
+ }
490
+
491
+
492
+ def merge_req(worksheet, start_col, end_col, start_row, end_row):
493
+ return {
494
+ "mergeCells": {
495
+ "mergeType": "MERGE_ALL",
496
+ "range": {
497
+ "sheetId": worksheet.ws.id,
498
+ "startRowIndex": start_row - 1,
499
+ "endRowIndex": end_row,
500
+ "startColumnIndex": start_col - 1,
501
+ "endColumnIndex": end_col,
502
+ },
503
+ }
504
+ }
505
+
506
+
507
+ def hide_gridlines_req(worksheet):
508
+ return {
509
+ "updateSheetProperties": {
510
+ "properties": {
511
+ "sheetId": worksheet.ws.id,
512
+ "gridProperties": {
513
+ "hideGridlines": True,
514
+ },
515
+ },
516
+ "fields": "gridProperties.hideGridlines",
517
+ }
518
+ }
519
+
520
+
521
+ def col_to_a(col):
522
+ return rowcol_to_a1(row=1, col=col).replace("1", "")
523
+
524
+
525
+ class _StubWs:
526
+ """Placeholder for a worksheet not yet created in Google Sheets.
527
+
528
+ Supports the subset of gspread.Worksheet attributes used during the
529
+ data-building phase (before submit). After _flush_worksheet_ops the
530
+ stub is replaced with a real gspread Worksheet object.
531
+
532
+ Each stub gets a unique negative ``id`` so that GridRange objects
533
+ created during data building can be patched to the real sheet ID
534
+ after the sheets are actually created.
535
+ """
536
+
537
+ _next_id = -1
538
+
539
+ def __init__(self, title):
540
+ self.title = title
541
+ self.id = _StubWs._next_id
542
+ _StubWs._next_id -= 1
543
+ self.col_count = 60
544
+ self.row_count = 200
545
+
546
+
547
+ def _set_sheet_size(ws, row_count, col_count):
548
+ """Update the cached grid dimensions on a worksheet (real or stub)."""
549
+ if isinstance(ws, _StubWs):
550
+ ws.row_count = row_count
551
+ ws.col_count = col_count
552
+ else:
553
+ ws._properties["gridProperties"]["rowCount"] = row_count
554
+ ws._properties["gridProperties"]["columnCount"] = col_count
555
+
556
+
557
+ class GoogleSheetWriter:
558
+ def __init__(self, gc, spreadsheet_id, throttle=1, submit_order=None):
559
+ self.gc = gc
560
+ self.spreadsheet_id = spreadsheet_id
561
+ self.spreadsheet = self.gc.open_by_key(self.spreadsheet_id)
562
+ self.worksheets = None
563
+ self.cells = defaultdict(list)
564
+ self.batch_clear_ranges = []
565
+ self.formatter = batch_updater(self.spreadsheet)
566
+ self.fmt = defaultdict(lambda: new_fmt())
567
+ self.throttle = throttle
568
+ self.ignore = set() # this is for debugging large tables, where you generate the cells, but don't submit them, preserving user-entered data
569
+ self.submit_order = submit_order
570
+ self._pending_deletes = [] # (name, real_gspread_ws) to delete
571
+ self._pending_creates = [] # names to create
572
+
573
+ def batch_clear(self):
574
+ if not self.batch_clear_ranges:
575
+ return
576
+ body = {"ranges": self.batch_clear_ranges}
577
+ response = self.spreadsheet.values_batch_clear(body=body)
578
+ self.batch_clear_ranges = []
579
+ return response
580
+
581
+ def get_worksheet(self, name, remove=False, defer=False, hidden=False):
582
+ if self.worksheets is None:
583
+ self.worksheets = {
584
+ ws.title: Worksheet(ws, self) for ws in self.spreadsheet.worksheets()
585
+ }
586
+
587
+ if name in self.worksheets and remove and name not in self.ignore:
588
+ real_ws = self.worksheets[name].ws
589
+ if not isinstance(real_ws, _StubWs):
590
+ self._pending_deletes.append((name, real_ws))
591
+ self.worksheets.pop(name, None)
592
+
593
+ if name not in self.worksheets:
594
+ stub = _StubWs(name)
595
+ self.worksheets[name] = Worksheet(stub, self, defer=defer, hidden=hidden)
596
+ self._pending_creates.append(name)
597
+ else:
598
+ ws_wrapper = self.worksheets[name]
599
+ ws_wrapper.defer = defer
600
+ ws_wrapper.hidden = hidden
601
+
602
+ return self.worksheets[name]
603
+
604
+ def _flush_worksheet_ops(self):
605
+ """Batch all pending worksheet creates/deletes into one API call.
606
+
607
+ Uses rename-then-delete to avoid name conflicts when recreating
608
+ sheets (remove=True). After the batch, refreshes the worksheet
609
+ list so all Worksheet wrappers hold real gspread objects.
610
+ """
611
+ if not self._pending_deletes and not self._pending_creates:
612
+ return
613
+
614
+ reqs = []
615
+
616
+ # Rename sheets scheduled for deletion to avoid name collisions
617
+ # with newly created sheets that reuse the same name.
618
+ for i, (name, real_ws) in enumerate(self._pending_deletes):
619
+ reqs.append(
620
+ {
621
+ "updateSheetProperties": {
622
+ "properties": {
623
+ "sheetId": real_ws.id,
624
+ "title": f"___to_delete_{i}",
625
+ },
626
+ "fields": "title",
627
+ }
628
+ }
629
+ )
630
+
631
+ # Create new sheets large enough for data (min 200x60 so that
632
+ # conditional formatting ranges aren't clipped — shrinking to
633
+ # exact data size happens later in _format via delete_req).
634
+ for name in self._pending_creates:
635
+ cells = self.cells.get(name, [])
636
+ ws_wrapper = self.worksheets[name]
637
+ if cells:
638
+ max_col = max(max(c.col for c in cells), ws_wrapper.force_max_col, 60)
639
+ max_row = max(max(c.row for c in cells), ws_wrapper.force_max_row, 200)
640
+ else:
641
+ max_col = max(60, ws_wrapper.force_max_col)
642
+ max_row = max(200, ws_wrapper.force_max_row)
643
+ reqs.append(
644
+ {
645
+ "addSheet": {
646
+ "properties": {
647
+ "title": name,
648
+ "sheetType": "GRID",
649
+ "gridProperties": {
650
+ "rowCount": max_row,
651
+ "columnCount": max_col,
652
+ },
653
+ }
654
+ }
655
+ }
656
+ )
657
+
658
+ # Delete the renamed sheets
659
+ for _name, real_ws in self._pending_deletes:
660
+ reqs.append({"deleteSheet": {"sheetId": real_ws.id}})
661
+
662
+ n_create = len(self._pending_creates)
663
+ n_delete = len(self._pending_deletes)
664
+ print(f"Batch worksheet management: {n_create} create, {n_delete} delete...")
665
+ self._chunked_batch_update(reqs)
666
+
667
+ # Refresh worksheet objects so stubs are replaced with real
668
+ # gspread Worksheet objects (needed by gspread_formatting).
669
+ if self._pending_creates:
670
+ real_ws_map = {ws.title: ws for ws in self.spreadsheet.worksheets()}
671
+ for name in self._pending_creates:
672
+ if name in real_ws_map and name in self.worksheets:
673
+ stub = self.worksheets[name].ws
674
+ real_ws = real_ws_map[name]
675
+ self.worksheets[name].ws = real_ws
676
+ # Patch GridRange.sheetId in conditional formatting
677
+ # rules that were built against the stub's temporary
678
+ # negative ID.
679
+ if isinstance(stub, _StubWs):
680
+ self._patch_stub_ids(name, stub.id, real_ws.id)
681
+
682
+ self._pending_deletes = []
683
+ self._pending_creates = []
684
+ self.sleep()
685
+
686
+ def _patch_stub_ids(self, ws_name, stub_id, real_id):
687
+ """Replace temporary stub sheet IDs with real IDs in stored format rules."""
688
+ fmt = self.fmt.get(ws_name)
689
+ if not fmt:
690
+ return
691
+ for rule in fmt.get("conditional_format_rules", []):
692
+ for grid_range in getattr(rule, "ranges", []):
693
+ if grid_range.sheetId == stub_id:
694
+ grid_range.sheetId = real_id
695
+
696
+ def _chunked_batch_update(self, reqs):
697
+ """Execute batch_update in chunks to stay within API limits."""
698
+ if not reqs:
699
+ return
700
+ for i in range(0, len(reqs), BATCH_UPDATE_CHUNK_SIZE):
701
+ chunk = reqs[i : i + BATCH_UPDATE_CHUNK_SIZE]
702
+ self.spreadsheet.batch_update({"requests": chunk})
703
+
704
+ def sleep(self):
705
+ if self.throttle:
706
+ time.sleep(self.throttle)
707
+
708
+ def _format(self, extra_reqs=None):
709
+ reqs = list(extra_reqs or [])
710
+ for name in self.fmt:
711
+ if name in self.ignore:
712
+ continue
713
+ ws = self.worksheets[name]
714
+ fmt_dict = self.fmt[name]
715
+ cells = self.cells[name]
716
+ max_cells_col = max(max([c.col for c in cells]), ws.force_max_col)
717
+ max_cells_row = max(max([c.row for c in cells]), ws.force_max_row)
718
+ col_count = ws.ws.col_count
719
+ row_count = ws.ws.row_count
720
+ if col_count > max_cells_col:
721
+ reqs.append(delete_req(ws, max_cells_col, col_count))
722
+ if row_count > max_cells_row:
723
+ reqs.append(delete_req(ws, max_cells_row, row_count, rows=True))
724
+ if ws.init_format:
725
+ ws.init_format(ws, max_col=max_cells_col, max_row=max_cells_row)
726
+
727
+ hidden_cols = fmt_dict["hidden_cols"]
728
+ if hidden_cols:
729
+ for col in hidden_cols:
730
+ reqs.append(hide_req(ws, col))
731
+ hidden_rows = fmt_dict["hidden_rows"]
732
+ if hidden_rows:
733
+ for row in hidden_rows:
734
+ reqs.append(hide_req(ws, row, rows=True))
735
+
736
+ if fmt_dict["hide_gridlines"]:
737
+ reqs.append(hide_gridlines_req(ws))
738
+
739
+ cw = fmt_dict["column_widths"]
740
+ column_widths = [(col_to_a(col), cw[col]) for col in cw]
741
+ if column_widths:
742
+ self.formatter.set_column_widths(ws.ws, column_widths)
743
+
744
+ rh = fmt_dict["row_heights"]
745
+ row_heights = [(f"{row}:{row}", rh[row]) for row in rh]
746
+ if row_heights:
747
+ self.formatter.set_row_heights(ws.ws, row_heights)
748
+
749
+ merged_cells = fmt_dict["merged_cells"]
750
+ if merged_cells:
751
+ for tup in merged_cells:
752
+ reqs.append(merge_req(ws, tup[0], tup[1], tup[2], tup[3]))
753
+
754
+ if cr := fmt_dict["cell_ranges"]:
755
+ for rng in cr:
756
+ if isinstance(rng[1], Cf):
757
+ import pdb
758
+
759
+ pdb.set_trace()
760
+ self.formatter.format_cell_range(ws.ws, rng[0], rng[1])
761
+
762
+ if dv := fmt_dict["dv_cell_ranges"]:
763
+ for rng in dv:
764
+ self.formatter.set_data_validation_for_cell_range(
765
+ ws.ws, rng[0], rng[1]
766
+ )
767
+
768
+ if fmt_dict["freeze_col"] or fmt_dict["freeze_row"]:
769
+ self.formatter.set_frozen(
770
+ ws.ws, rows=fmt_dict["freeze_row"], cols=fmt_dict["freeze_col"]
771
+ )
772
+
773
+ if fmt_dict["conditional_format_rules"]:
774
+ print(f"Applying conditional formatting for {ws.ws.title}...")
775
+ rules = get_conditional_format_rules(ws.ws)
776
+ rules.clear()
777
+ rules.extend(fmt_dict["conditional_format_rules"])
778
+ rules.save()
779
+ self.sleep()
780
+
781
+ # Merge gspread_formatting's accumulated requests with our
782
+ # structural requests into a single batch_update call.
783
+ format_reqs = list(self.formatter.requests)
784
+ del self.formatter.requests[:]
785
+ all_reqs = format_reqs + reqs
786
+ if all_reqs:
787
+ print(f"Applying formatting and structure ({len(all_reqs)} requests)...")
788
+ self._chunked_batch_update(all_reqs)
789
+ self.sleep()
790
+
791
+ def submit(self):
792
+ # Phase 1: Batch worksheet creation/deletion (1-2 API calls)
793
+ self._flush_worksheet_ops()
794
+
795
+ # Phase 2: Batch clear user-specified ranges (1 API call)
796
+ if self.batch_clear_ranges:
797
+ print("Clearing user-specified ranges...")
798
+ self.batch_clear()
799
+
800
+ # Phase 3: Determine submission order
801
+ self.submit_order = self.submit_order or []
802
+ deferred = []
803
+ normal = []
804
+ for name in self.worksheets:
805
+ if name not in self.cells:
806
+ continue
807
+ if name in self.submit_order:
808
+ continue
809
+ elif self.worksheets[name].defer:
810
+ deferred.append(name)
811
+ else:
812
+ normal.append(name)
813
+ self.submit_order.extend(normal)
814
+ self.submit_order.extend(deferred)
815
+
816
+ # Phase 4: Expand sheets that are too small for their data.
817
+ # Only expand here — shrinking to exact size happens later in
818
+ # _format (via delete_req) AFTER conditional formatting is applied.
819
+ size_reqs = []
820
+ for name in self.submit_order:
821
+ if name in self.ignore:
822
+ continue
823
+ cells = self.cells.get(name, [])
824
+ if not cells:
825
+ continue
826
+ ws = self.worksheets[name]
827
+ max_col = max(max(c.col for c in cells), ws.force_max_col)
828
+ max_row = max(max(c.row for c in cells), ws.force_max_row)
829
+ needs_expand = ws.ws.col_count < max_col or ws.ws.row_count < max_row
830
+ if needs_expand:
831
+ new_col = max(ws.ws.col_count, max_col)
832
+ new_row = max(ws.ws.row_count, max_row)
833
+ size_reqs.append(
834
+ {
835
+ "updateSheetProperties": {
836
+ "properties": {
837
+ "sheetId": ws.ws.id,
838
+ "gridProperties": {
839
+ "rowCount": new_row,
840
+ "columnCount": new_col,
841
+ },
842
+ },
843
+ "fields": "gridProperties.rowCount,gridProperties.columnCount",
844
+ }
845
+ }
846
+ )
847
+ _set_sheet_size(ws.ws, new_row, new_col)
848
+
849
+ if size_reqs:
850
+ print(f"Setting sheet sizes ({len(size_reqs)} sheets)...")
851
+ self._chunked_batch_update(size_reqs)
852
+ self.sleep()
853
+
854
+ # Phase 5: Batch write all cell values (2 API calls: clear + update)
855
+ clear_ranges = []
856
+ value_data = []
857
+ total_cells = 0
858
+ sheets_to_write = []
859
+ for name in self.submit_order:
860
+ if name in self.ignore:
861
+ continue
862
+ cells = self.cells.get(name, [])
863
+ if not cells:
864
+ continue
865
+ ws_title = self.worksheets[name].ws.title
866
+ clear_ranges.append(f"'{ws_title}'")
867
+
868
+ max_row = max(c.row for c in cells)
869
+ max_col = max(c.col for c in cells)
870
+ n_cells = max_row * max_col
871
+ total_cells += n_cells
872
+ print(f" Preparing {ws_title} ({max_row}x{max_col} = {n_cells} cells)...")
873
+ grid = [["" for _ in range(max_col)] for _ in range(max_row)]
874
+ for cell in cells:
875
+ if cell.value is not None:
876
+ grid[cell.row - 1][cell.col - 1] = cell.value
877
+
878
+ range_str = f"'{ws_title}'!A1:{rowcol_to_a1(max_row, max_col)}"
879
+ value_data.append({"range": range_str, "values": grid})
880
+ sheets_to_write.append(ws_title)
881
+
882
+ if clear_ranges:
883
+ print(f"Clearing {len(clear_ranges)} sheets...")
884
+ self.spreadsheet.values_batch_clear(body={"ranges": clear_ranges})
885
+
886
+ if value_data:
887
+ print(f"Writing {len(value_data)} sheets ({total_cells} cells total)...")
888
+ self.spreadsheet.values_batch_update(
889
+ body={
890
+ "valueInputOption": "USER_ENTERED",
891
+ "data": value_data,
892
+ }
893
+ )
894
+ self.sleep()
895
+
896
+ # Phase 6: Batch formatting + hide worksheets (1 API call)
897
+ hide_reqs = []
898
+ for name in self.submit_order:
899
+ if name in self.ignore:
900
+ continue
901
+ if self.worksheets[name].hidden:
902
+ hide_reqs.append(
903
+ {
904
+ "updateSheetProperties": {
905
+ "properties": {
906
+ "sheetId": self.worksheets[name].ws.id,
907
+ "hidden": True,
908
+ },
909
+ "fields": "hidden",
910
+ }
911
+ }
912
+ )
913
+
914
+ self._format(extra_reqs=hide_reqs)
915
+
916
+ print(
917
+ f"Finished! View results at https://docs.google.com/spreadsheets/d/{self.spreadsheet_id}/edit"
918
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.0.8"
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: google_sheet_writer
3
+ Version: 0.0.8
4
+ Summary: Easy wrapper around gspread and gspread-formatting
5
+ Home-page: https://gitlab.com/peczony/google_sheet_writer
6
+ Author: Alexander Pecheny
7
+ Author-email: ap@pecheny.me
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE.md
13
+ Requires-Dist: gspread
14
+ Requires-Dist: gspread-formatting
15
+ Dynamic: author
16
+ Dynamic: author-email
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license-file
22
+ Dynamic: requires-dist
23
+ Dynamic: summary
24
+
25
+ # google_sheet_writer
26
+
27
+ **google_sheet_writer** is an object-oriented wrapper around (amazing!) [gspread](https://github.com/burnash/gspread) and [gspread_formatting](https://github.com/robin900/gspread-formatting) that allows to programmatically create Google Sheets tables in an easy way.
28
+
29
+ On top of being convenient, it strives to minimize the amount of requests to save API quota.
30
+
31
+ Install in development mode: `pip install -e .`
32
+
33
+ See examples in `examples` folder.
34
+
35
+ The examples assume `United Kingdom` spreadsheet locale (can be changed in `File → Settings` **prior to launching generation script**). Other locales (e.g. `Russia`) might not work.
36
+
37
+
38
+ ## Version history
39
+
40
+ ### v0.0.8
41
+
42
+ - Batch API calls for dramatically faster spreadsheet generation (especially 10+ sheets)
43
+ - Worksheet creation/deletion batched into a single API call instead of one per sheet
44
+ - Cell values for all sheets written in one `values_batch_update` call
45
+ - Sheet resizing via `updateSheetProperties` instead of writing empty cells
46
+ - Formatting and structural requests merged into a single `batch_update` call
47
+ - Worksheet hiding folded into the formatting batch
48
+
49
+ ### v0.0.7
50
+
51
+ - After successfully finishing table generation, a link to the table is printed to the console
52
+
53
+ ### v0.0.6
54
+
55
+ - Raise exception if users tries to set cursor's x/y attributes to floats
56
+
57
+ ### v0.0.5
58
+
59
+ - Added support for older pythons (3.9 and newer)
@@ -0,0 +1,11 @@
1
+ LICENSE.md
2
+ README.md
3
+ setup.py
4
+ google_sheet_writer/__init__.py
5
+ google_sheet_writer/classes.py
6
+ google_sheet_writer/version.py
7
+ google_sheet_writer.egg-info/PKG-INFO
8
+ google_sheet_writer.egg-info/SOURCES.txt
9
+ google_sheet_writer.egg-info/dependency_links.txt
10
+ google_sheet_writer.egg-info/requires.txt
11
+ google_sheet_writer.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ gspread
2
+ gspread-formatting
@@ -0,0 +1 @@
1
+ google_sheet_writer
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ import os
2
+ from setuptools import setup
3
+
4
+
5
+ DIR = os.path.dirname(os.path.abspath(__file__))
6
+
7
+
8
+ def get_version():
9
+ version = {}
10
+ with open(
11
+ os.path.join(DIR, "google_sheet_writer", "version.py"), encoding="utf8"
12
+ ) as f:
13
+ exec(f.read(), version)
14
+ return version["__version__"]
15
+
16
+
17
+ with open(os.path.join(DIR, "README.md"), "r", encoding="utf8") as f:
18
+ long_description = f.read()
19
+
20
+
21
+ setup(
22
+ name="google_sheet_writer",
23
+ version=get_version(),
24
+ author="Alexander Pecheny",
25
+ author_email="ap@pecheny.me",
26
+ description="Easy wrapper around gspread and gspread-formatting",
27
+ long_description=long_description,
28
+ long_description_content_type="text/markdown",
29
+ url="https://gitlab.com/peczony/google_sheet_writer",
30
+ classifiers=[
31
+ "Programming Language :: Python :: 3",
32
+ "License :: OSI Approved :: MIT License",
33
+ "Operating System :: OS Independent",
34
+ ],
35
+ packages=["google_sheet_writer"],
36
+ install_requires=[
37
+ "gspread",
38
+ "gspread-formatting"
39
+ ],
40
+ )