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.
@@ -0,0 +1,10 @@
1
+ """tha-google-runner: typed gspread wrapper for Google Sheets."""
2
+
3
+ from tha_google_runner.errors import GoogleError
4
+ from tha_google_runner.sheets import ThaSheets
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = [
8
+ "GoogleError",
9
+ "ThaSheets",
10
+ ]
@@ -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
+ )
@@ -0,0 +1,2 @@
1
+ class GoogleError(Exception):
2
+ """Raised for tha-google-runner errors."""
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
+ [![CI](https://github.com/tha-guy-nate/tha-google-runner/actions/workflows/ci.yml/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.