← back to Rentv Sheet Enrich
scaffold: Sheets API connect + stdlib read/write lib with light-green fill marking
915689c9f898306d46e266290423d7c1c40aa20e · 2026-08-13 09:18:01 -0700 · Steve Abrams
Files touched
A .gitignoreA connect.pyA lib.py
Diff
commit 915689c9f898306d46e266290423d7c1c40aa20e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 13 09:18:01 2026 -0700
scaffold: Sheets API connect + stdlib read/write lib with light-green fill marking
---
.gitignore | 9 +++++++
connect.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 175 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..49021c1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+data/*.csv
+data/*.json
diff --git a/connect.py b/connect.py
new file mode 100644
index 0000000..44e22f6
--- /dev/null
+++ b/connect.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""
+connect.py — ONE-TIME Google Sheets auth for the RENTV CRE-sheet enrichment.
+
+Reuses the existing DESKTOP-type OAuth client (YT_UPLOAD_CLIENT_ID/SECRET) that
+already auto-trusts the http://localhost loopback — so there is NOTHING to register
+in the Google console. Runs the consent flow once, captures a long-lived refresh
+token, and stores it as SHEETS_REFRESH_TOKEN in the secure secrets store.
+
+IMPORTANT: log in with the Google account that can EDIT the target spreadsheet.
+
+Run it yourself in this session with the `!` prefix so the browser opens here:
+ ! python3 ~/Projects/rentv-sheet-enrich/connect.py
+Then click "Allow".
+"""
+import os, sys, json, time, urllib.parse, urllib.request, http.server, threading, webbrowser, re
+
+ENV_PATH = os.path.expanduser("~/Projects/secrets-manager/.env")
+
+def load_env(path):
+ d = {}
+ try:
+ for ln in open(path):
+ m = re.match(r'^([A-Z0-9_]+)=(.*)$', ln.rstrip("\n"))
+ if m:
+ d[m.group(1)] = m.group(2)
+ except FileNotFoundError:
+ pass
+ return d
+
+def upsert_env(path, kv):
+ try: lines = open(path).read().splitlines()
+ except FileNotFoundError: lines = []
+ keys = set(kv); out, seen = [], set()
+ for ln in lines:
+ m = re.match(r'^([A-Z0-9_]+)=', ln)
+ if m and m.group(1) in keys:
+ out.append(f'{m.group(1)}={kv[m.group(1)]}'); seen.add(m.group(1))
+ else:
+ out.append(ln)
+ for k, v in kv.items():
+ if k not in seen: out.append(f'{k}={v}')
+ open(path, "w").write("\n".join(out) + "\n")
+
+envf = load_env(ENV_PATH)
+CID = os.environ.get("YT_UPLOAD_CLIENT_ID") or envf.get("YT_UPLOAD_CLIENT_ID") or sys.exit("No desktop OAuth client id found (YT_UPLOAD_CLIENT_ID)")
+CSEC = os.environ.get("YT_UPLOAD_CLIENT_SECRET") or envf.get("YT_UPLOAD_CLIENT_SECRET") or sys.exit("No desktop OAuth client secret found")
+PORT = int(os.environ.get("SHEETS_PORT", "8733"))
+REDIRECT = f"http://localhost:{PORT}/"
+SCOPE = "https://www.googleapis.com/auth/spreadsheets"
+
+hold = {}
+class H(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ p = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
+ if "code" in p: hold["code"] = p["code"][0]
+ elif "error" in p: hold["error"] = p["error"][0]
+ self.send_response(200); self.send_header("Content-Type", "text/html"); self.end_headers()
+ self.wfile.write(b"<h2 style='font-family:sans-serif'>Sheets connected. You can close this tab.</h2>")
+ def log_message(self, *a): pass
+
+srv = http.server.HTTPServer(("127.0.0.1", PORT), H)
+threading.Thread(target=srv.serve_forever, daemon=True).start()
+auth = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode({
+ "client_id": CID, "redirect_uri": REDIRECT, "response_type": "code",
+ "scope": SCOPE, "access_type": "offline", "prompt": "consent"})
+print("AUTH_URL:", auth, flush=True)
+try: webbrowser.open(auth)
+except Exception: pass
+print("Waiting for consent (Allow in the browser)...", flush=True)
+for _ in range(300):
+ if hold: break
+ time.sleep(1)
+srv.shutdown()
+if "code" not in hold: sys.exit(f"No consent captured: {hold or 'timeout'}")
+
+data = urllib.parse.urlencode({"code": hold["code"], "client_id": CID, "client_secret": CSEC,
+ "redirect_uri": REDIRECT, "grant_type": "authorization_code"}).encode()
+tok = json.load(urllib.request.urlopen(urllib.request.Request("https://oauth2.googleapis.com/token", data)))
+rt = tok.get("refresh_token")
+if not rt: sys.exit("No refresh_token returned (revoke prior grant + retry).")
+upsert_env(ENV_PATH, {"SHEETS_REFRESH_TOKEN": rt})
+print("CONNECTED — Sheets refresh token saved. Enrichment can now write to the sheet.", flush=True)
diff --git a/lib.py b/lib.py
new file mode 100644
index 0000000..e12be3f
--- /dev/null
+++ b/lib.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""
+lib.py — pure-stdlib Google Sheets read/write helpers with light-green fill marking.
+
+No external deps (no gspread / google-api-python-client). Refreshes an access token
+from SHEETS_REFRESH_TOKEN + the desktop OAuth client, then talks to the Sheets REST API.
+
+Every cell this library writes is stamped with a LIGHT-GREEN background so Steve can
+see at a glance what was auto-filled vs. what was already in the sheet.
+"""
+import os, re, json, urllib.parse, urllib.request
+
+SID = os.environ.get("SHEET_ID", "1yDmtj9Wxe71SaoQm8xpFsn722B4Ws67hTtPIHeXoN9E")
+ENV_PATH = os.path.expanduser("~/Projects/secrets-manager/.env")
+API = "https://sheets.googleapis.com/v4/spreadsheets"
+
+# Light green fill for auto-populated cells (≈ #cbe6b4)
+GREEN = {"red": 0.796, "green": 0.902, "blue": 0.706}
+
+def _env():
+ d = {}
+ try:
+ for ln in open(ENV_PATH):
+ m = re.match(r'^([A-Z0-9_]+)=(.*)$', ln.rstrip("\n"))
+ if m: d[m.group(1)] = m.group(2)
+ except FileNotFoundError:
+ pass
+ return d
+
+def access_token():
+ e = _env()
+ cid = e.get("YT_UPLOAD_CLIENT_ID"); csec = e.get("YT_UPLOAD_CLIENT_SECRET")
+ rt = e.get("SHEETS_REFRESH_TOKEN")
+ if not rt:
+ raise SystemExit("Not connected yet — run connect.py first (SHEETS_REFRESH_TOKEN missing).")
+ data = urllib.parse.urlencode({
+ "client_id": cid, "client_secret": csec,
+ "refresh_token": rt, "grant_type": "refresh_token"}).encode()
+ tok = json.load(urllib.request.urlopen(
+ urllib.request.Request("https://oauth2.googleapis.com/token", data)))
+ return tok["access_token"]
+
+def _req(method, url, tok, body=None):
+ data = json.dumps(body).encode() if body is not None else None
+ r = urllib.request.Request(url, data=data, method=method,
+ headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
+ try:
+ return json.load(urllib.request.urlopen(r))
+ except urllib.error.HTTPError as ex:
+ raise SystemExit(f"Sheets API {method} {url.split('?')[0]} -> {ex.code}: {ex.read().decode()[:500]}")
+
+def get_meta(tok):
+ return _req("GET", f"{API}/{SID}?fields=sheets(properties(sheetId,title,gridProperties))", tok)
+
+def read_tab(tok, title):
+ """Return the tab's values as a list of rows (list of str), padded ragged."""
+ rng = urllib.parse.quote(f"{title}")
+ res = _req("GET", f"{API}/{SID}/values/{rng}?majorDimension=ROWS", tok)
+ return res.get("values", [])
+
+def col_letter(idx0):
+ s = ""; n = idx0
+ while True:
+ s = chr(ord('A') + n % 26) + s; n = n // 26 - 1
+ if n < 0: break
+ return s
+
+def batch_fill(tok, sheet_id, cells):
+ """
+ cells: list of dicts {row0, col0, value} (0-based row/col indices)
+ Writes each value AND stamps a light-green background, in ONE batchUpdate.
+ """
+ reqs = []
+ for c in cells:
+ reqs.append({"updateCells": {
+ "start": {"sheetId": sheet_id, "rowIndex": c["row0"], "columnIndex": c["col0"]},
+ "rows": [{"values": [{
+ "userEnteredValue": {"stringValue": str(c["value"])},
+ "userEnteredFormat": {"backgroundColor": GREEN}}]}],
+ "fields": "userEnteredValue,userEnteredFormat.backgroundColor"}})
+ if not reqs:
+ return {"skipped": True}
+ return _req("POST", f"{API}/{SID}:batchUpdate", tok, {"requests": reqs})
(oldest)
·
back to Rentv Sheet Enrich
·
enrich Layer 1: website-from-email-domain engine + dry-run ( f280a73 →