tha-google-runner 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.
- tha_google_runner/__init__.py +10 -0
- tha_google_runner/auth.py +48 -0
- tha_google_runner/errors.py +2 -0
- tha_google_runner/py.typed +0 -0
- tha_google_runner/sheets.py +337 -0
- tha_google_runner-0.1.0.dist-info/METADATA +325 -0
- tha_google_runner-0.1.0.dist-info/RECORD +9 -0
- tha_google_runner-0.1.0.dist-info/WHEEL +4 -0
- tha_google_runner-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import google.auth
|
|
6
|
+
import google.auth.exceptions
|
|
7
|
+
import gspread
|
|
8
|
+
|
|
9
|
+
from tha_google_runner.errors import GoogleError
|
|
10
|
+
|
|
11
|
+
_SCOPES = [
|
|
12
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
13
|
+
"https://www.googleapis.com/auth/drive",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
_DEFAULT_TOKEN = Path.home() / ".config" / "tha-google-runner" / "token.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_client(
|
|
20
|
+
credentials_file: str | None,
|
|
21
|
+
token_file: str | None,
|
|
22
|
+
) -> gspread.Client:
|
|
23
|
+
"""Build a gspread client using ADC or an OAuth2 client_secrets.json file.
|
|
24
|
+
|
|
25
|
+
Auth priority:
|
|
26
|
+
1. Application Default Credentials (ADC) — if credentials_file is None.
|
|
27
|
+
Run `gcloud auth application-default login` once to set this up.
|
|
28
|
+
2. OAuth2 user flow — if credentials_file points to a client_secrets.json.
|
|
29
|
+
A browser window opens on first run; the token is cached for subsequent runs.
|
|
30
|
+
"""
|
|
31
|
+
if credentials_file is None:
|
|
32
|
+
try:
|
|
33
|
+
creds, _ = google.auth.default(scopes=_SCOPES)
|
|
34
|
+
return gspread.Client(auth=creds)
|
|
35
|
+
except google.auth.exceptions.DefaultCredentialsError:
|
|
36
|
+
raise GoogleError(
|
|
37
|
+
"No Google credentials found. Either:\n"
|
|
38
|
+
" 1. Run: gcloud auth application-default login\n"
|
|
39
|
+
" 2. Pass credentials_file= pointing to your client_secrets.json\n"
|
|
40
|
+
"See the tha-google-runner README for setup instructions."
|
|
41
|
+
) from None
|
|
42
|
+
|
|
43
|
+
token = token_file or str(_DEFAULT_TOKEN)
|
|
44
|
+
Path(token).parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
return gspread.oauth(
|
|
46
|
+
credentials_filename=credentials_file,
|
|
47
|
+
authorized_user_filename=token,
|
|
48
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Literal, cast
|
|
5
|
+
|
|
6
|
+
import gspread
|
|
7
|
+
import gspread.exceptions
|
|
8
|
+
from gspread.utils import rowcol_to_a1
|
|
9
|
+
|
|
10
|
+
from tha_google_runner.auth import build_client
|
|
11
|
+
from tha_google_runner.errors import GoogleError
|
|
12
|
+
|
|
13
|
+
OnConflict = Literal["update_all", "update_first", "update_last", "raise", "skip"]
|
|
14
|
+
|
|
15
|
+
_URL_RE = re.compile(r"/spreadsheets/d/([a-zA-Z0-9_-]+)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ThaSheets:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
credentials_file: str | None = None,
|
|
23
|
+
token_file: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._credentials_file = credentials_file
|
|
26
|
+
self._token_file = token_file
|
|
27
|
+
self._client: gspread.Client | None = None
|
|
28
|
+
self.rows: list[dict[str, Any]] = []
|
|
29
|
+
|
|
30
|
+
def _get_client(self) -> gspread.Client:
|
|
31
|
+
if self._client is None:
|
|
32
|
+
self._client = build_client(self._credentials_file, self._token_file)
|
|
33
|
+
return self._client
|
|
34
|
+
|
|
35
|
+
def _resolve_id(self, spreadsheet_id: str | None, url: str | None) -> str:
|
|
36
|
+
if url is not None:
|
|
37
|
+
m = _URL_RE.search(url)
|
|
38
|
+
if not m:
|
|
39
|
+
raise GoogleError(f"Could not parse spreadsheet ID from URL: {url}")
|
|
40
|
+
return m.group(1)
|
|
41
|
+
if spreadsheet_id is not None:
|
|
42
|
+
return spreadsheet_id
|
|
43
|
+
raise GoogleError("Provide either spreadsheet_id= or url=")
|
|
44
|
+
|
|
45
|
+
def _get_spreadsheet(self, sid: str) -> gspread.Spreadsheet:
|
|
46
|
+
client = self._get_client()
|
|
47
|
+
try:
|
|
48
|
+
return client.open_by_key(sid)
|
|
49
|
+
except gspread.exceptions.SpreadsheetNotFound:
|
|
50
|
+
raise GoogleError(f"Spreadsheet not found: {sid}") from None
|
|
51
|
+
|
|
52
|
+
def _get_worksheet(self, sid: str, sheet_name: str | None) -> gspread.Worksheet:
|
|
53
|
+
spreadsheet = self._get_spreadsheet(sid)
|
|
54
|
+
if sheet_name is None:
|
|
55
|
+
return spreadsheet.sheet1
|
|
56
|
+
try:
|
|
57
|
+
return spreadsheet.worksheet(sheet_name)
|
|
58
|
+
except gspread.exceptions.WorksheetNotFound:
|
|
59
|
+
raise GoogleError(f"Sheet '{sheet_name}' not found in {sid}") from None
|
|
60
|
+
|
|
61
|
+
def _normalize_rows(
|
|
62
|
+
self,
|
|
63
|
+
rows: list[dict[str, Any]] | list[list[Any]],
|
|
64
|
+
existing_headers: list[str],
|
|
65
|
+
) -> tuple[list[str], list[dict[str, Any]]]:
|
|
66
|
+
"""Convert rows to list[dict] and detect/drop an included header row.
|
|
67
|
+
|
|
68
|
+
For list[dict] input, keys become headers (unchanged behavior).
|
|
69
|
+
For list[list] input, auto-detects if rows[0] is a header row by comparing
|
|
70
|
+
it against existing_headers. If they match exactly, the header row is dropped.
|
|
71
|
+
When existing_headers is empty (new/replaced sheet), rows[0] is always headers.
|
|
72
|
+
"""
|
|
73
|
+
if not rows:
|
|
74
|
+
return existing_headers, []
|
|
75
|
+
if isinstance(rows[0], dict):
|
|
76
|
+
dict_rows = cast(list[dict[str, Any]], rows)
|
|
77
|
+
headers = existing_headers if existing_headers else list(dict_rows[0].keys())
|
|
78
|
+
return headers, dict_rows
|
|
79
|
+
list_rows = cast(list[list[Any]], rows)
|
|
80
|
+
first_as_strs = [str(v) for v in list_rows[0]]
|
|
81
|
+
if existing_headers:
|
|
82
|
+
if first_as_strs == existing_headers:
|
|
83
|
+
headers = existing_headers
|
|
84
|
+
data = list_rows[1:]
|
|
85
|
+
else:
|
|
86
|
+
headers = existing_headers
|
|
87
|
+
data = list_rows
|
|
88
|
+
else:
|
|
89
|
+
headers = first_as_strs
|
|
90
|
+
data = list_rows[1:]
|
|
91
|
+
return headers, [dict(zip(headers, row, strict=False)) for row in data]
|
|
92
|
+
|
|
93
|
+
def read(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
spreadsheet_id: str | None = None,
|
|
97
|
+
url: str | None = None,
|
|
98
|
+
sheet_name: str | None = None,
|
|
99
|
+
) -> list[dict[str, Any]]:
|
|
100
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
101
|
+
ws = self._get_worksheet(sid, sheet_name)
|
|
102
|
+
self.rows = ws.get_all_records()
|
|
103
|
+
return self.rows
|
|
104
|
+
|
|
105
|
+
def append_rows(
|
|
106
|
+
self,
|
|
107
|
+
rows: list[dict[str, Any]] | list[list[Any]],
|
|
108
|
+
*,
|
|
109
|
+
spreadsheet_id: str | None = None,
|
|
110
|
+
url: str | None = None,
|
|
111
|
+
sheet_name: str | None = None,
|
|
112
|
+
) -> int:
|
|
113
|
+
if not rows:
|
|
114
|
+
return 0
|
|
115
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
116
|
+
ws = self._get_worksheet(sid, sheet_name)
|
|
117
|
+
existing_headers = ws.row_values(1)
|
|
118
|
+
headers, dict_rows = self._normalize_rows(rows, existing_headers)
|
|
119
|
+
if not existing_headers:
|
|
120
|
+
ws.append_row(headers)
|
|
121
|
+
values = [[row.get(h, "") for h in headers] for row in dict_rows]
|
|
122
|
+
ws.append_rows(values)
|
|
123
|
+
self.rows = dict_rows
|
|
124
|
+
return len(dict_rows)
|
|
125
|
+
|
|
126
|
+
def update_rows(
|
|
127
|
+
self,
|
|
128
|
+
rows: list[dict[str, Any]] | list[list[Any]],
|
|
129
|
+
*,
|
|
130
|
+
spreadsheet_id: str | None = None,
|
|
131
|
+
url: str | None = None,
|
|
132
|
+
sheet_name: str | None = None,
|
|
133
|
+
) -> int:
|
|
134
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
135
|
+
ws = self._get_worksheet(sid, sheet_name)
|
|
136
|
+
ws.clear()
|
|
137
|
+
if not rows:
|
|
138
|
+
self.rows = []
|
|
139
|
+
return 0
|
|
140
|
+
headers, dict_rows = self._normalize_rows(rows, [])
|
|
141
|
+
values = [headers] + [[row.get(h, "") for h in headers] for row in dict_rows]
|
|
142
|
+
ws.update("A1", values) # type: ignore[arg-type]
|
|
143
|
+
self.rows = dict_rows
|
|
144
|
+
return len(dict_rows)
|
|
145
|
+
|
|
146
|
+
def create(
|
|
147
|
+
self,
|
|
148
|
+
title: str,
|
|
149
|
+
*,
|
|
150
|
+
rows: list[dict[str, Any]] | list[list[Any]] | None = None,
|
|
151
|
+
sheet_name: str = "Sheet1",
|
|
152
|
+
) -> str:
|
|
153
|
+
client = self._get_client()
|
|
154
|
+
spreadsheet = client.create(title)
|
|
155
|
+
ws = spreadsheet.sheet1
|
|
156
|
+
ws.update_title(sheet_name)
|
|
157
|
+
if rows:
|
|
158
|
+
headers, dict_rows = self._normalize_rows(rows, [])
|
|
159
|
+
values = [headers] + [[row.get(h, "") for h in headers] for row in dict_rows]
|
|
160
|
+
ws.update("A1", values) # type: ignore[arg-type]
|
|
161
|
+
self.rows = dict_rows
|
|
162
|
+
return spreadsheet.id
|
|
163
|
+
|
|
164
|
+
def delete(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
spreadsheet_id: str | None = None,
|
|
168
|
+
url: str | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
171
|
+
self._get_client().del_spreadsheet(sid)
|
|
172
|
+
self.rows = []
|
|
173
|
+
|
|
174
|
+
def list_sheets(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
spreadsheet_id: str | None = None,
|
|
178
|
+
url: str | None = None,
|
|
179
|
+
) -> list[str]:
|
|
180
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
181
|
+
spreadsheet = self._get_spreadsheet(sid)
|
|
182
|
+
return [ws.title for ws in spreadsheet.worksheets()]
|
|
183
|
+
|
|
184
|
+
def add_sheet(
|
|
185
|
+
self,
|
|
186
|
+
sheet_name: str,
|
|
187
|
+
*,
|
|
188
|
+
spreadsheet_id: str | None = None,
|
|
189
|
+
url: str | None = None,
|
|
190
|
+
rows: list[dict[str, Any]] | list[list[Any]] | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
193
|
+
spreadsheet = self._get_spreadsheet(sid)
|
|
194
|
+
ws = spreadsheet.add_worksheet(title=sheet_name, rows=1000, cols=26)
|
|
195
|
+
if rows:
|
|
196
|
+
headers, dict_rows = self._normalize_rows(rows, [])
|
|
197
|
+
values = [headers] + [[row.get(h, "") for h in headers] for row in dict_rows]
|
|
198
|
+
ws.update("A1", values) # type: ignore[arg-type]
|
|
199
|
+
self.rows = dict_rows
|
|
200
|
+
|
|
201
|
+
def delete_sheet(
|
|
202
|
+
self,
|
|
203
|
+
sheet_name: str,
|
|
204
|
+
*,
|
|
205
|
+
spreadsheet_id: str | None = None,
|
|
206
|
+
url: str | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
209
|
+
spreadsheet = self._get_spreadsheet(sid)
|
|
210
|
+
try:
|
|
211
|
+
ws = spreadsheet.worksheet(sheet_name)
|
|
212
|
+
except gspread.exceptions.WorksheetNotFound:
|
|
213
|
+
raise GoogleError(f"Sheet '{sheet_name}' not found in {sid}") from None
|
|
214
|
+
spreadsheet.del_worksheet(ws)
|
|
215
|
+
|
|
216
|
+
def share(
|
|
217
|
+
self,
|
|
218
|
+
email: str,
|
|
219
|
+
*,
|
|
220
|
+
spreadsheet_id: str | None = None,
|
|
221
|
+
url: str | None = None,
|
|
222
|
+
role: str = "reader",
|
|
223
|
+
) -> None:
|
|
224
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
225
|
+
spreadsheet = self._get_spreadsheet(sid)
|
|
226
|
+
spreadsheet.share(email, perm_type="user", role=role)
|
|
227
|
+
|
|
228
|
+
def upsert_rows(
|
|
229
|
+
self,
|
|
230
|
+
rows: list[dict[str, Any]] | list[list[Any]],
|
|
231
|
+
*,
|
|
232
|
+
key: str | list[str],
|
|
233
|
+
spreadsheet_id: str | None = None,
|
|
234
|
+
url: str | None = None,
|
|
235
|
+
sheet_name: str | None = None,
|
|
236
|
+
on_conflict: OnConflict = "update_all",
|
|
237
|
+
) -> int:
|
|
238
|
+
if not rows:
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
242
|
+
ws = self._get_worksheet(sid, sheet_name)
|
|
243
|
+
keys = [key] if isinstance(key, str) else list(key)
|
|
244
|
+
|
|
245
|
+
existing = ws.get_all_values()
|
|
246
|
+
existing_hdrs: list[str] = list(existing[0]) if existing else []
|
|
247
|
+
norm_hdrs, dict_rows = self._normalize_rows(rows, existing_hdrs)
|
|
248
|
+
|
|
249
|
+
# Empty sheet — write everything fresh
|
|
250
|
+
if not existing:
|
|
251
|
+
values = [norm_hdrs] + [[row.get(h, "") for h in norm_hdrs] for row in dict_rows]
|
|
252
|
+
ws.update("A1", values) # type: ignore[arg-type]
|
|
253
|
+
self.rows = dict_rows
|
|
254
|
+
return len(dict_rows)
|
|
255
|
+
|
|
256
|
+
headers = list(existing[0])
|
|
257
|
+
data_rows = existing[1:]
|
|
258
|
+
|
|
259
|
+
missing_keys = [k for k in keys if k not in headers]
|
|
260
|
+
if missing_keys:
|
|
261
|
+
raise GoogleError(f"Key column(s) not found in sheet headers: {missing_keys}")
|
|
262
|
+
|
|
263
|
+
# Collect new columns from incoming rows, preserving order
|
|
264
|
+
seen: set[str] = set(headers)
|
|
265
|
+
new_cols: list[str] = []
|
|
266
|
+
for row in dict_rows:
|
|
267
|
+
for col in row:
|
|
268
|
+
if col not in seen:
|
|
269
|
+
new_cols.append(col)
|
|
270
|
+
seen.add(col)
|
|
271
|
+
if new_cols:
|
|
272
|
+
headers = headers + new_cols
|
|
273
|
+
ws.update("A1", [headers]) # type: ignore[arg-type]
|
|
274
|
+
|
|
275
|
+
col_index = {h: i for i, h in enumerate(headers)}
|
|
276
|
+
key_col_indices = [col_index[k] for k in keys]
|
|
277
|
+
|
|
278
|
+
# Build key → list of sheet row numbers (1-indexed, data starts at row 2)
|
|
279
|
+
index: dict[tuple[str, ...], list[int]] = {}
|
|
280
|
+
for i, row_vals in enumerate(data_rows):
|
|
281
|
+
row_key = tuple(
|
|
282
|
+
str(row_vals[c]) if c < len(row_vals) else "" for c in key_col_indices
|
|
283
|
+
)
|
|
284
|
+
index.setdefault(row_key, []).append(i + 2)
|
|
285
|
+
|
|
286
|
+
cell_updates: list[dict[str, Any]] = []
|
|
287
|
+
rows_to_append: list[list[Any]] = []
|
|
288
|
+
upserted = 0
|
|
289
|
+
|
|
290
|
+
for row in dict_rows:
|
|
291
|
+
row_key = tuple(str(row.get(k, "")) for k in keys)
|
|
292
|
+
matches = index.get(row_key, [])
|
|
293
|
+
|
|
294
|
+
if not matches:
|
|
295
|
+
rows_to_append.append([row.get(h, "") for h in headers])
|
|
296
|
+
upserted += 1
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
if len(matches) > 1:
|
|
300
|
+
if on_conflict == "raise":
|
|
301
|
+
raise GoogleError(
|
|
302
|
+
f"Duplicate rows found for key {dict(zip(keys, row_key, strict=True))}"
|
|
303
|
+
)
|
|
304
|
+
elif on_conflict == "skip":
|
|
305
|
+
continue
|
|
306
|
+
elif on_conflict == "update_first":
|
|
307
|
+
matches = [matches[0]]
|
|
308
|
+
elif on_conflict == "update_last":
|
|
309
|
+
matches = [matches[-1]]
|
|
310
|
+
# update_all: use all matches as-is
|
|
311
|
+
|
|
312
|
+
for sheet_row in matches:
|
|
313
|
+
for col_name, val in row.items():
|
|
314
|
+
if col_name in col_index:
|
|
315
|
+
cell = rowcol_to_a1(sheet_row, col_index[col_name] + 1)
|
|
316
|
+
cell_updates.append({"range": cell, "values": [[val]]})
|
|
317
|
+
upserted += 1
|
|
318
|
+
|
|
319
|
+
if cell_updates:
|
|
320
|
+
ws.batch_update(cell_updates)
|
|
321
|
+
if rows_to_append:
|
|
322
|
+
ws.append_rows(rows_to_append)
|
|
323
|
+
|
|
324
|
+
self.rows = dict_rows
|
|
325
|
+
return upserted
|
|
326
|
+
|
|
327
|
+
def clear(
|
|
328
|
+
self,
|
|
329
|
+
*,
|
|
330
|
+
spreadsheet_id: str | None = None,
|
|
331
|
+
url: str | None = None,
|
|
332
|
+
sheet_name: str | None = None,
|
|
333
|
+
) -> None:
|
|
334
|
+
sid = self._resolve_id(spreadsheet_id, url)
|
|
335
|
+
ws = self._get_worksheet(sid, sheet_name)
|
|
336
|
+
ws.clear()
|
|
337
|
+
self.rows = []
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tha-google-runner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Tabular Helper API library that wraps Google Sheets with a typed, consistent interface built on gspread.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tha-guy-nate/tha-google-runner
|
|
6
|
+
Project-URL: Issues, https://github.com/tha-guy-nate/tha-google-runner/issues
|
|
7
|
+
Author: Nate Wright
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: google,gspread,helper,sheets,tabular
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: google-auth-oauthlib>=1.0
|
|
22
|
+
Requires-Dist: google-auth>=2.0
|
|
23
|
+
Requires-Dist: gspread>=6.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# tha-google-runner
|
|
31
|
+
|
|
32
|
+
[](https://github.com/tha-guy-nate/tha-google-runner/actions/workflows/ci.yml)
|
|
33
|
+
|
|
34
|
+
A Tabular Helper API library that wraps Google Sheets with a typed, consistent interface built on gspread.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install tha-google-runner
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Authentication setup
|
|
43
|
+
|
|
44
|
+
`tha-google-runner` uses your **personal Google account** — not a service account. There are two ways to authenticate. Option 1 is recommended if you have the Google Cloud SDK installed.
|
|
45
|
+
|
|
46
|
+
> **Cost note:** This package is free and open source. The Google APIs it uses (Google Sheets API, Google Drive API) are also free for normal scripting workloads — Google provides a generous free tier (300 reads/min, 60 writes/min) that the vast majority of users will never exceed. Google Cloud Console may ask for a credit card when you first create a project to verify your identity, but **Google does not charge you** for the APIs used here. Any billing questions are between you and Google — not this package.
|
|
47
|
+
|
|
48
|
+
### Option 1 — Application Default Credentials (ADC)
|
|
49
|
+
|
|
50
|
+
This is the zero-config path. Run once in your terminal:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
gcloud auth application-default login
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A browser window opens, you sign in with your Google account, and credentials are saved to your machine. After that, `ThaSheets()` works with no arguments.
|
|
57
|
+
|
|
58
|
+
> Don't have `gcloud`? Install the [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) — it's a standalone CLI tool, roughly similar in spirit to the AWS CLI or the Azure CLI. It is not heavy and not venv-specific; install it once at the system level and every Python project on your machine can use ADC. Or skip it entirely and use Option 2.
|
|
59
|
+
|
|
60
|
+
### Option 2 — OAuth2 client secrets
|
|
61
|
+
|
|
62
|
+
Use this if you don't have `gcloud` or prefer not to install it.
|
|
63
|
+
|
|
64
|
+
**Step 1 — Create a Google Cloud project**
|
|
65
|
+
|
|
66
|
+
1. Go to [console.cloud.google.com](https://console.cloud.google.com/)
|
|
67
|
+
2. Click the project dropdown → **New Project** → give it any name → **Create**
|
|
68
|
+
|
|
69
|
+
**Step 2 — Enable the required APIs**
|
|
70
|
+
|
|
71
|
+
In your new project, go to **APIs & Services** → **Enable APIs and Services** and enable both:
|
|
72
|
+
- **Google Sheets API**
|
|
73
|
+
- **Google Drive API**
|
|
74
|
+
|
|
75
|
+
**Step 3 — Create OAuth2 credentials**
|
|
76
|
+
|
|
77
|
+
1. Go to **APIs & Services** → **Credentials** → **Create Credentials** → **OAuth 2.0 Client ID**
|
|
78
|
+
2. If prompted, configure the **OAuth consent screen** first:
|
|
79
|
+
- User type: **External** → fill in app name and your email → save
|
|
80
|
+
3. Application type: **Desktop app** → give it a name → **Create**
|
|
81
|
+
4. Click **Download JSON** and save the file (e.g., `client_secrets.json`)
|
|
82
|
+
|
|
83
|
+
**Step 4 — Use the credentials file**
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
sheets = ThaSheets(credentials_file="client_secrets.json")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
On the **first run**, a browser window opens for you to grant access. After that, the token is cached at `~/.config/tha-google-runner/token.json` and no browser is needed.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Quick start
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from tha_google_runner import ThaSheets
|
|
97
|
+
|
|
98
|
+
sheets = ThaSheets() # uses ADC; or pass credentials_file="client_secrets.json"
|
|
99
|
+
|
|
100
|
+
# Read all rows (first row is headers)
|
|
101
|
+
rows = sheets.read(spreadsheet_id="your-spreadsheet-id")
|
|
102
|
+
|
|
103
|
+
# Append new rows (writes headers automatically if the sheet is empty)
|
|
104
|
+
sheets.append_rows(
|
|
105
|
+
[{"name": "Alice", "score": 95}, {"name": "Bob", "score": 82}],
|
|
106
|
+
spreadsheet_id="your-spreadsheet-id",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Append using raw lists — header row auto-detected and dropped if it matches the sheet
|
|
110
|
+
sheets.append_rows(
|
|
111
|
+
[["name", "score"], ["Alice", 95]],
|
|
112
|
+
spreadsheet_id="your-spreadsheet-id",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Overwrite the entire sheet
|
|
116
|
+
sheets.update_rows(
|
|
117
|
+
[{"name": "Alice", "score": 95}],
|
|
118
|
+
spreadsheet_id="your-spreadsheet-id",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Upsert by key — inserts new rows, updates existing ones
|
|
122
|
+
sheets.upsert_rows(
|
|
123
|
+
[{"id": "1", "name": "Alice", "score": 99}],
|
|
124
|
+
key="id",
|
|
125
|
+
spreadsheet_id="your-spreadsheet-id",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Create a new spreadsheet and get its ID
|
|
129
|
+
spreadsheet_id = sheets.create("My Report", rows=[{"col": "val"}])
|
|
130
|
+
|
|
131
|
+
# Clear a sheet
|
|
132
|
+
sheets.clear(spreadsheet_id="your-spreadsheet-id")
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
> **Finding your spreadsheet ID:** It's the long string in the URL between `/d/` and `/edit`.
|
|
136
|
+
> `https://docs.google.com/spreadsheets/d/<spreadsheet-id>/edit`
|
|
137
|
+
>
|
|
138
|
+
> You can also pass `url=` instead of `spreadsheet_id=` to any method and the ID will be extracted automatically.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Row input formats
|
|
143
|
+
|
|
144
|
+
All write methods (`append_rows`, `update_rows`, `upsert_rows`, `create`, `add_sheet`) accept either format:
|
|
145
|
+
|
|
146
|
+
**`list[dict]`** — keys are column headers:
|
|
147
|
+
```python
|
|
148
|
+
[{"name": "Alice", "score": 95}, {"name": "Bob", "score": 82}]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**`list[list]`** — raw rows with automatic header detection:
|
|
152
|
+
```python
|
|
153
|
+
[["name", "score"], ["Alice", 95], ["Bob", 82]]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Header detection for `list[list]` input:
|
|
157
|
+
|
|
158
|
+
| Sheet state | First row matches existing headers? | Result |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| Has data | Yes | Header row dropped, rest appended as data |
|
|
161
|
+
| Has data | No | All rows treated as data |
|
|
162
|
+
| Empty / being replaced | — | First row always becomes headers |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## API
|
|
167
|
+
|
|
168
|
+
### `ThaSheets(*, credentials_file=None, token_file=None)`
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
ThaSheets(
|
|
172
|
+
credentials_file: str | None = None, # path to client_secrets.json; None uses ADC
|
|
173
|
+
token_file: str | None = None, # override token cache path (OAuth2 only)
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The Google client is built lazily on first use and cached for the lifetime of the instance.
|
|
178
|
+
After any write, `sheets.rows` is set to the data rows that were written (as `list[dict]`).
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
### `read(*, spreadsheet_id=None, url=None, sheet_name=None) -> list[dict]`
|
|
183
|
+
|
|
184
|
+
Read all rows. The first row is treated as headers; each subsequent row becomes a `dict`.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
rows = sheets.read(spreadsheet_id="spreadsheet-id")
|
|
188
|
+
rows = sheets.read(url="https://docs.google.com/spreadsheets/d/.../edit")
|
|
189
|
+
rows = sheets.read(spreadsheet_id="spreadsheet-id", sheet_name="Q1 Data")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
### `append_rows(rows, *, spreadsheet_id=None, url=None, sheet_name=None) -> int`
|
|
195
|
+
|
|
196
|
+
Append rows to an existing sheet. Returns the number of rows appended.
|
|
197
|
+
|
|
198
|
+
- If the sheet is empty, the headers are written first.
|
|
199
|
+
- Missing keys in a row are filled with `""`.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
count = sheets.append_rows(
|
|
203
|
+
[{"name": "Alice", "score": 95}],
|
|
204
|
+
spreadsheet_id="spreadsheet-id",
|
|
205
|
+
)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### `update_rows(rows, *, spreadsheet_id=None, url=None, sheet_name=None) -> int`
|
|
211
|
+
|
|
212
|
+
Overwrite all data in a sheet. Clears the sheet first, then writes headers + rows. Returns the number of rows written. Passing an empty list clears the sheet and returns `0`.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
count = sheets.update_rows(
|
|
216
|
+
[{"name": "Alice", "score": 95}],
|
|
217
|
+
spreadsheet_id="spreadsheet-id",
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
### `upsert_rows(rows, *, key, spreadsheet_id=None, url=None, sheet_name=None, on_conflict="update_all") -> int`
|
|
224
|
+
|
|
225
|
+
Insert new rows and update existing ones matched by key. Returns the number of rows upserted.
|
|
226
|
+
|
|
227
|
+
- `key` — column name (str) or list of column names for composite keys
|
|
228
|
+
- New columns in incoming rows are appended to the sheet automatically
|
|
229
|
+
- `on_conflict` controls what happens when multiple existing rows match the same key:
|
|
230
|
+
- `"update_all"` (default) — update every matching row
|
|
231
|
+
- `"update_first"` — update only the first match
|
|
232
|
+
- `"update_last"` — update only the last match
|
|
233
|
+
- `"skip"` — leave duplicates untouched
|
|
234
|
+
- `"raise"` — raise `GoogleError`
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
count = sheets.upsert_rows(
|
|
238
|
+
[{"id": "1", "name": "Alice", "score": 99}],
|
|
239
|
+
key="id",
|
|
240
|
+
spreadsheet_id="spreadsheet-id",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Composite key
|
|
244
|
+
count = sheets.upsert_rows(rows, key=["year", "month"], spreadsheet_id="spreadsheet-id")
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
### `create(title, *, rows=None, sheet_name="Sheet1") -> str`
|
|
250
|
+
|
|
251
|
+
Create a new spreadsheet. Returns the new spreadsheet's ID.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
sid = sheets.create("My Report")
|
|
255
|
+
sid = sheets.create("My Report", rows=[{"col": "val"}], sheet_name="Data")
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
### `delete(*, spreadsheet_id=None, url=None) -> None`
|
|
261
|
+
|
|
262
|
+
Permanently delete a spreadsheet.
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
sheets.delete(spreadsheet_id="spreadsheet-id")
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
### `list_sheets(*, spreadsheet_id=None, url=None) -> list[str]`
|
|
271
|
+
|
|
272
|
+
Return the names of all worksheets in a spreadsheet.
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
names = sheets.list_sheets(spreadsheet_id="spreadsheet-id")
|
|
276
|
+
# ["Sheet1", "Q1 Data", "Archive"]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
### `add_sheet(sheet_name, *, spreadsheet_id=None, url=None, rows=None) -> None`
|
|
282
|
+
|
|
283
|
+
Add a new worksheet to an existing spreadsheet. Optionally write initial rows.
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
sheets.add_sheet("Q2 Data", spreadsheet_id="spreadsheet-id")
|
|
287
|
+
sheets.add_sheet("Q2 Data", spreadsheet_id="spreadsheet-id", rows=[{"col": "val"}])
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
### `delete_sheet(sheet_name, *, spreadsheet_id=None, url=None) -> None`
|
|
293
|
+
|
|
294
|
+
Delete a worksheet from a spreadsheet.
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
sheets.delete_sheet("Archive", spreadsheet_id="spreadsheet-id")
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
### `share(email, *, spreadsheet_id=None, url=None, role="reader") -> None`
|
|
303
|
+
|
|
304
|
+
Share a spreadsheet with a user. `role` can be `"reader"`, `"writer"`, or `"owner"`.
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
sheets.share("colleague@example.com", spreadsheet_id="spreadsheet-id", role="writer")
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
### `clear(*, spreadsheet_id=None, url=None, sheet_name=None) -> None`
|
|
313
|
+
|
|
314
|
+
Clear all data in a sheet. Resets `sheets.rows` to `[]`.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
sheets.clear(spreadsheet_id="spreadsheet-id")
|
|
318
|
+
sheets.clear(spreadsheet_id="spreadsheet-id", sheet_name="Archive")
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## License
|
|
324
|
+
|
|
325
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
tha_google_runner/__init__.py,sha256=hE41klzEkb99ONiaYx8YRkKYPgnMJKDntar5YsckplE,236
|
|
2
|
+
tha_google_runner/auth.py,sha256=KfBYMUZA2avjhTh59FjO90yn2uwXTrlS4yuHKFrJJZY,1671
|
|
3
|
+
tha_google_runner/errors.py,sha256=IGT5dUKAxGxlknlMJCHAIfJQXAgcZNQK4RvnmgBrdk4,77
|
|
4
|
+
tha_google_runner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
tha_google_runner/sheets.py,sha256=Mft1HFR0E2NUPgj0TWu76EvTOQ67yT8rvsYGqoDW24Q,11877
|
|
6
|
+
tha_google_runner-0.1.0.dist-info/METADATA,sha256=31HrYZj_Uw1uxAzQWhgDvN9vPNFPIe3bYq4q6Wn8pKU,10512
|
|
7
|
+
tha_google_runner-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
tha_google_runner-0.1.0.dist-info/licenses/LICENSE,sha256=bCtVwn7MJmnj7wfasPJG_OXozVSo1RGg1FXERKSv6ps,1070
|
|
9
|
+
tha_google_runner-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathan Wright
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|