Skip to content

Commit 8c13d83

Browse files
committed
Helper file to do Google sheet edits
1 parent 7c1a2f9 commit 8c13d83

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

sheets_helper.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# sheets_helper.py
2+
#
3+
# Direct Google Sheets integration for spotify_monitor.py, replacing the Gmail-scraping
4+
# Google Apps Script (jmk_script.txt / kel_script.txt) with a live write from the monitor
5+
# itself. Authenticates via a one-time OAuth desktop consent flow, caching a refresh token
6+
# so subsequent runs are unattended.
7+
#
8+
# Every write goes through update_spreadsheet(), which also owns a local file-based queue:
9+
# if a write fails, the row is queued instead of lost, and is retried (in order) on the
10+
# next call before any new row is attempted. Callers get told whether this call is the
11+
# moment the failure started or the moment the queue fully drained, so they can alert once
12+
# per transition instead of on every retry.
13+
14+
import os
15+
import json
16+
17+
try:
18+
import gspread
19+
from google.oauth2.credentials import Credentials
20+
from google_auth_oauthlib.flow import InstalledAppFlow
21+
from google.auth.transport.requests import Request
22+
LIBS_AVAILABLE = True
23+
IMPORT_ERROR = None
24+
except ImportError as e:
25+
LIBS_AVAILABLE = False
26+
IMPORT_ERROR = e
27+
28+
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
29+
30+
QUEUE_DIR = os.path.dirname(os.path.abspath(__file__))
31+
32+
_client_cache = None
33+
_worksheet_cache = {}
34+
35+
36+
def _invalidate_cache():
37+
global _client_cache, _worksheet_cache
38+
_client_cache = None
39+
_worksheet_cache = {}
40+
41+
42+
def _get_credentials(client_file, token_file):
43+
creds = None
44+
if os.path.isfile(token_file):
45+
creds = Credentials.from_authorized_user_file(token_file, SCOPES)
46+
if not creds or not creds.valid:
47+
if creds and creds.expired and creds.refresh_token:
48+
creds.refresh(Request())
49+
else:
50+
flow = InstalledAppFlow.from_client_secrets_file(client_file, SCOPES)
51+
creds = flow.run_local_server(port=0)
52+
with open(token_file, "w", encoding="utf-8") as f:
53+
f.write(creds.to_json())
54+
return creds
55+
56+
57+
def _get_worksheet(spreadsheet_id, tab_name, client_file, token_file):
58+
global _client_cache
59+
cache_key = (spreadsheet_id, tab_name)
60+
if cache_key in _worksheet_cache:
61+
return _worksheet_cache[cache_key]
62+
if _client_cache is None:
63+
creds = _get_credentials(client_file, token_file)
64+
_client_cache = gspread.authorize(creds)
65+
sh = _client_cache.open_by_key(spreadsheet_id)
66+
ws = sh.worksheet(tab_name)
67+
_worksheet_cache[cache_key] = ws
68+
return ws
69+
70+
71+
def _write_row(spreadsheet_id, tab_name, row, client_file, token_file):
72+
try:
73+
ws = _get_worksheet(spreadsheet_id, tab_name, client_file, token_file)
74+
ws.insert_row(row, index=2, value_input_option="USER_ENTERED")
75+
except Exception as e:
76+
print(f"* Error writing to Google Sheet (tab '{tab_name}'): {e}")
77+
_invalidate_cache()
78+
return False
79+
80+
# Inserted rows inherit the number format of whichever row they push down, so a single
81+
# badly-formatted row (e.g. one that picked up a date+time format instead of date-only)
82+
# keeps propagating that format onto every row inserted above it from then on. Explicitly
83+
# re-pin column A's format on every write so that chain can never take hold. Best-effort:
84+
# a formatting hiccup here shouldn't mark the row itself as failed/queued, since the data
85+
# is already safely written.
86+
try:
87+
ws.format("A2:A2", {"numberFormat": {"type": "DATE", "pattern": "M/d/yyyy"}})
88+
except Exception as e:
89+
print(f"* Warning: wrote row to Google Sheet (tab '{tab_name}') but failed to fix date format: {e}")
90+
91+
return True
92+
93+
94+
def _queue_file(err_code):
95+
return os.path.join(QUEUE_DIR, f"spreadsheet_queue_{err_code}.jsonl")
96+
97+
98+
def _queue_length(err_code):
99+
path = _queue_file(err_code)
100+
if not os.path.isfile(path):
101+
return 0
102+
with open(path, "r", encoding="utf-8") as f:
103+
return sum(1 for line in f if line.strip())
104+
105+
106+
def _enqueue(err_code, row):
107+
with open(_queue_file(err_code), "a", encoding="utf-8") as f:
108+
f.write(json.dumps(row) + "\n")
109+
110+
111+
def _drain_queue(spreadsheet_id, tab_name, err_code, client_file, token_file):
112+
"""Attempts to write all queued rows in order, oldest first. Stops at the first
113+
failure so remaining rows stay queued in their original order. Returns True if the
114+
queue is fully empty afterwards."""
115+
path = _queue_file(err_code)
116+
if not os.path.isfile(path):
117+
return True
118+
119+
with open(path, "r", encoding="utf-8") as f:
120+
lines = [line for line in f if line.strip()]
121+
122+
remaining = list(lines)
123+
for line in lines:
124+
row = json.loads(line)
125+
if _write_row(spreadsheet_id, tab_name, row, client_file, token_file):
126+
remaining.pop(0)
127+
else:
128+
break
129+
130+
if remaining:
131+
with open(path, "w", encoding="utf-8") as f:
132+
f.writelines(remaining)
133+
else:
134+
os.remove(path)
135+
136+
return not remaining
137+
138+
139+
def update_spreadsheet(err_code, spreadsheet_id, tab_name, row, client_file, token_file):
140+
"""
141+
Writes `row` to the given spreadsheet tab, draining any previously queued rows first.
142+
143+
Returns (success, entered_error, recovered):
144+
success - True if `row` is now live in the sheet, False if it was queued.
145+
entered_error - True only on the call where the queue goes from empty to non-empty.
146+
recovered - True only on the call where a previously non-empty queue fully drains.
147+
"""
148+
if not LIBS_AVAILABLE:
149+
print(f"* Error: Google Sheets libraries not installed ({IMPORT_ERROR}); queuing row.\n"
150+
f" To install, run: pip install gspread google-auth-oauthlib")
151+
had_queue = _queue_length(err_code) > 0
152+
_enqueue(err_code, row)
153+
return False, not had_queue, False
154+
155+
had_queue = _queue_length(err_code) > 0
156+
recovered = False
157+
158+
if had_queue:
159+
if _drain_queue(spreadsheet_id, tab_name, err_code, client_file, token_file):
160+
recovered = True
161+
had_queue = False
162+
163+
if not had_queue:
164+
if _write_row(spreadsheet_id, tab_name, row, client_file, token_file):
165+
return True, False, recovered
166+
_enqueue(err_code, row)
167+
return False, True, recovered
168+
169+
# Queue still has older rows pending after the drain attempt - keep FIFO order intact
170+
# rather than trying (and likely failing) the current row out of order.
171+
_enqueue(err_code, row)
172+
return False, False, recovered

0 commit comments

Comments
 (0)