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.
- google_sheet_writer-0.0.8/LICENSE.md +19 -0
- google_sheet_writer-0.0.8/PKG-INFO +59 -0
- google_sheet_writer-0.0.8/README.md +35 -0
- google_sheet_writer-0.0.8/google_sheet_writer/__init__.py +23 -0
- google_sheet_writer-0.0.8/google_sheet_writer/classes.py +918 -0
- google_sheet_writer-0.0.8/google_sheet_writer/version.py +1 -0
- google_sheet_writer-0.0.8/google_sheet_writer.egg-info/PKG-INFO +59 -0
- google_sheet_writer-0.0.8/google_sheet_writer.egg-info/SOURCES.txt +11 -0
- google_sheet_writer-0.0.8/google_sheet_writer.egg-info/dependency_links.txt +1 -0
- google_sheet_writer-0.0.8/google_sheet_writer.egg-info/requires.txt +2 -0
- google_sheet_writer-0.0.8/google_sheet_writer.egg-info/top_level.txt +1 -0
- google_sheet_writer-0.0.8/setup.cfg +4 -0
- google_sheet_writer-0.0.8/setup.py +40 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
google_sheet_writer
|
|
@@ -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
|
+
)
|