alfrd-gsheet 0.2.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,33 @@
1
+ """Google Sheet plugin. Importing the manifest never loads Google libraries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from alfrd.extensions import PanelSpec, Plugin, StepHooks
6
+
7
+ from .actions import ACTIONS
8
+ from .cli import cli
9
+ from .settings import FIELDS, check, make_engine
10
+ from .studio import auto_panel, check_project, evaluate
11
+
12
+
13
+ def before(ctx):
14
+ engine = make_engine()
15
+ snapshot = engine.before(ctx)
16
+ return (engine, snapshot) if snapshot is not None else None
17
+
18
+
19
+ def after(ctx, state):
20
+ if state is not None:
21
+ engine, snapshot = state
22
+ return engine.after(ctx, snapshot)
23
+ if ctx.readopted:
24
+ return make_engine().after(ctx, None)
25
+ return None
26
+
27
+ plugin = Plugin(
28
+ id="gsheet", version="0.2.0", alfrd_api=">=1,<2", title="Google Sheet",
29
+ description="Two-way sync around launched steps, writing only changed cells.", cli=cli,
30
+ settings=FIELDS, check=check, step_hooks=StepHooks(before=before, after=after, timeout=30),
31
+ panels=(PanelSpec("gsheet_sync", evaluate=evaluate, client=True, title="Google Sheet", auto=auto_panel),),
32
+ web="web", check_project=check_project, project_actions=ACTIONS,
33
+ )
alfrd_gsheet/a1.py ADDED
@@ -0,0 +1,63 @@
1
+ """One-based A1 addresses and unambiguous, trimmed header matching.
2
+
3
+ Always quote worksheet titles, including titles that happen to resemble cells.
4
+ Range origins keep partial reads (e.g. B3:AZ) aligned with absolute sheet cells.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Sequence
11
+
12
+
13
+ def letters(index: int) -> str:
14
+ if isinstance(index, bool) or not isinstance(index, int) or index < 1:
15
+ raise ValueError("column index must be a positive integer")
16
+ out = ""
17
+ while index:
18
+ index, remainder = divmod(index - 1, 26)
19
+ out = chr(65 + remainder) + out
20
+ return out
21
+
22
+
23
+ def index(column: str) -> int:
24
+ if not isinstance(column, str) or not re.fullmatch(r"[A-Za-z]+", column):
25
+ raise ValueError("column letters must contain only A-Z")
26
+ out = 0
27
+ for char in column.upper():
28
+ out = out * 26 + ord(char) - 64
29
+ return out
30
+
31
+
32
+ def quote(sheet: str) -> str:
33
+ return "'" + sheet.replace("'", "''") + "'"
34
+
35
+
36
+ def cell(sheet: str, row: int, col: int) -> str:
37
+ if isinstance(row, bool) or not isinstance(row, int) or row < 1:
38
+ raise ValueError("row index must be a positive integer")
39
+ return f"{quote(sheet)}!{letters(col)}{row}"
40
+
41
+
42
+ def column(headers: Sequence[str], name: str, *, first_col: int = 1) -> int:
43
+ """Prefer a header over letters; repeated normalized headers are unsafe."""
44
+ matches = [i + first_col for i, h in enumerate(headers) if h.strip().casefold() == name.strip().casefold()]
45
+ if len(matches) > 1:
46
+ raise ValueError(f"ambiguous sheet column {name!r}")
47
+ if matches:
48
+ return matches[0]
49
+ if re.fullmatch(r"[A-Z]+", name):
50
+ return index(name)
51
+ raise ValueError(f"unknown sheet column {name!r}")
52
+
53
+
54
+ def origin(value: str | None) -> tuple[int, int]:
55
+ if value is None:
56
+ return 1, 1
57
+ match = re.fullmatch(r"([A-Za-z]+)([1-9][0-9]*)?(?::([A-Za-z]+)([1-9][0-9]*)?)?", value)
58
+ if not match:
59
+ raise ValueError("read_range must be an A1 range without a worksheet title")
60
+ col, row = index(match[1]), int(match[2] or 1)
61
+ if match[3] and (index(match[3]) < col or (match[4] and int(match[4]) < row)):
62
+ raise ValueError("read_range ends before it starts")
63
+ return row, col
@@ -0,0 +1,454 @@
1
+ """Studio project actions; credentials stay server-side and writes use revision locks.
2
+
3
+ Drafts use the same parser and sync engine as the CLI. Google failures are reduced
4
+ to a safe connection message; arbitrary transport bodies are never returned.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ from alfrd.extensions import ProjectAction
17
+ from alfrd.yaml_text import dump
18
+
19
+ from . import a1, columns, loopback, mapping, oauth, project, settings, studio
20
+ from .client import SyncError
21
+
22
+ ORDER = ("version", "enabled", "spreadsheet_id", "worksheet", "gid", "header_row", "rows",
23
+ "read_range", "verify_before_write", "outbound", "inbound")
24
+ COMMENTS_NOTICE = "Saving rewrites the file; comments are removed."
25
+ TIMEOUT = 25
26
+ BACKFILL_TIMEOUT = 290
27
+
28
+
29
+ def _read(root: Path) -> tuple[dict | None, str]:
30
+ """Read bytes once so the draft and its revision always describe the same file."""
31
+ import hashlib
32
+
33
+ try:
34
+ raw = (root / project.MAPPING).read_bytes()
35
+ except FileNotFoundError:
36
+ return None, ""
37
+ sha = hashlib.sha256(raw).hexdigest()
38
+ try:
39
+ data = yaml.safe_load(raw.decode("utf-8"))
40
+ except (yaml.YAMLError, UnicodeError):
41
+ raise mapping.MappingError("cannot read alfrd.gsheet.yaml: expected valid UTF-8 YAML") from None
42
+ if not isinstance(data, dict):
43
+ raise mapping.MappingError("alfrd.gsheet.yaml must be a mapping")
44
+ return data, sha
45
+
46
+
47
+ def _ordered(data: dict) -> dict:
48
+ return {k: data[k] for k in dict.fromkeys([*ORDER, *data]) if k in data}
49
+
50
+
51
+ def _revision(payload: dict) -> str:
52
+ sha = payload.get("base_sha256")
53
+ if not isinstance(sha, str) or (sha and not re.fullmatch(r"[0-9a-f]{64}", sha)):
54
+ raise mapping.MappingError("base_sha256 must be the loaded mapping revision")
55
+ return sha
56
+
57
+
58
+ def _sid(values: dict, payload: dict) -> str:
59
+ return mapping.spreadsheet_id(payload.get("spreadsheet") or values.get("default_spreadsheet_id"))
60
+
61
+
62
+ def _sheet(client: Any, sid: str, payload: dict, deadline: float) -> str:
63
+ if "gid" in payload and payload.get("worksheet") is not None:
64
+ raise mapping.MappingError("choose worksheet or gid, not both")
65
+ if "gid" in payload:
66
+ gid = payload["gid"]
67
+ if type(gid) is not int or gid < 0:
68
+ raise mapping.MappingError("gid must be a non-negative integer")
69
+ return client.worksheet_title(sid, gid, deadline=deadline)
70
+ title = payload.get("worksheet")
71
+ if not isinstance(title, str) or not title.strip():
72
+ raise mapping.MappingError("worksheet must be a non-empty string")
73
+ return title
74
+
75
+
76
+ def _header_row(payload: dict) -> int:
77
+ row = payload.get("header_row", 1)
78
+ if type(row) is not int or row < 1:
79
+ raise mapping.MappingError("header_row must be a positive integer")
80
+ return row
81
+
82
+
83
+ def _path(message: str, prefix: str = "") -> str:
84
+ """Point parser/validator messages at editable controls without exposing YAML."""
85
+ if prefix:
86
+ for field in ("column", "step", "field", "format", "when", "template", "to", "plan_column"):
87
+ if field in message or (field == "column" and "destination" in message):
88
+ return f"{prefix}.{field}"
89
+ return prefix
90
+ for field in ("code_column", "key_column", "missing_row", "match_against"):
91
+ if field in message:
92
+ return f"rows.{field}"
93
+ for field in ("read_range", "header_row",
94
+ "verify_before_write", "spreadsheet_id", "worksheet", "version", "enabled", "outbound", "inbound"):
95
+ if field in message:
96
+ return field
97
+ return "mapping"
98
+
99
+
100
+ def _errors(data: Any, values: dict, steps: tuple, header=None, first_col=1) -> tuple[Any, list[dict]]:
101
+ """Collect independent rule errors and duplicate destinations through the shared checks."""
102
+ errors = []
103
+
104
+ def add(exc, prefix=""):
105
+ message = " ".join(str(exc).split())[:800]
106
+ item = {"path": _path(message, prefix), "message": message}
107
+ if item not in errors:
108
+ errors.append(item)
109
+
110
+ if not isinstance(data, dict):
111
+ return None, [{"path": "mapping", "message": "mapping must be an object"}]
112
+ # Disabled drafts still need to be valid so the editor can safely re-enable them.
113
+ checked = {**data, "enabled": True}
114
+ if type(data.get("enabled", True)) is not bool:
115
+ errors.append({"path": "enabled", "message": "enabled must be true or false"})
116
+ base = {**checked, "outbound": [], "inbound": []}
117
+ config = None
118
+ try:
119
+ config = mapping.parse(base, values)
120
+ mapping.validate(config, steps, header, first_col=first_col)
121
+ except mapping.MappingError as exc:
122
+ add(exc)
123
+ if config is not None and header is not None and errors[-1]["path"] == "mapping":
124
+ for name, column in (("key_column", config.key_column), ("code_column", config.code_column)):
125
+ if not column:
126
+ continue
127
+ try:
128
+ a1.column(header, column, first_col=first_col)
129
+ except ValueError:
130
+ errors[-1]["path"] = f"rows.{name}"
131
+ break
132
+ return None, errors
133
+ for kind in ("outbound", "inbound"):
134
+ rules = checked.get(kind, [])
135
+ if not isinstance(rules, list) or any(not isinstance(r, dict) for r in rules):
136
+ errors.append({"path": kind, "message": f"{kind} must be a list of rules"})
137
+ continue
138
+ accepted = []
139
+ for i, rule in enumerate(rules):
140
+ try:
141
+ candidate = mapping.parse({**base, kind: [rule]}, values)
142
+ mapping.validate(candidate, steps, header, first_col=first_col)
143
+ # A cumulative check assigns duplicates to the later conflicting row.
144
+ candidate = mapping.parse({**base, kind: [*accepted, rule]}, values)
145
+ mapping.validate(candidate, steps, header, first_col=first_col)
146
+ accepted.append(rule)
147
+ except mapping.MappingError as exc:
148
+ add(exc, f"{kind}[{i}]")
149
+ try:
150
+ return mapping.parse(checked, values), errors
151
+ except mapping.MappingError:
152
+ return None, errors
153
+
154
+
155
+ def _validation(root: Path, values: dict, data: Any, *, live: bool) -> dict:
156
+ steps = tuple(project._execution(root).step_ids)
157
+ config, errors = _errors(data, values, steps)
158
+ result = {"errors": errors, "warnings": []}
159
+ if config is None or not live:
160
+ return result
161
+ try:
162
+ state = settings.make_engine(TIMEOUT, values=values).snapshot(config, steps, validate_rules=False, project_root=root)
163
+ except mapping.MappingError as exc:
164
+ result["errors"].append({"path": _path(str(exc)), "message": str(exc)})
165
+ except Exception: # noqa: BLE001 - never expose credentials or Google response bodies
166
+ result["warnings"].append("The sheet could not be checked. Check the connection in Settings → Plugins → Google Sheet.")
167
+ else:
168
+ missing = mapping.missing_columns(config, steps, state.header, first_col=state.first_col)
169
+ if missing:
170
+ result["missing_columns"] = missing
171
+ _, live_errors = _errors(data, values, steps, state.header, state.first_col)
172
+ result["errors"] += [e for e in live_errors if e not in result["errors"]]
173
+ return result
174
+
175
+
176
+ def state(root: Path, values: dict, payload: dict) -> dict:
177
+ read_errors = []
178
+ try:
179
+ data, sha = _read(root)
180
+ except mapping.MappingError as exc:
181
+ data, sha = None, project.mapping_sha(root)
182
+ read_errors = [{"path": "mapping", "message": str(exc)}]
183
+ steps = tuple(project._execution(root).step_ids)
184
+ errors = _errors(data, values, steps)[1] if data is not None else read_errors
185
+ records, _ = studio._history(root / project.HISTORY)
186
+ latest = records[0] if records else {}
187
+ fields = [{"id": name, "label": name.replace("_", " ").capitalize(), "takes_key": False}
188
+ for name in sorted(mapping.FIELDS)]
189
+ fields += [{"id": name, "label": label, "takes_key": True, "keys": keys}
190
+ for name, label, keys in (("usage.*", "Resource usage", sorted(mapping.USAGE)),
191
+ ("agent_usage.*", "Agent usage", []), ("results.*", "Results", []))]
192
+ fields.append({"id": "template", "label": "Template", "takes_key": False})
193
+ auth = settings.auth_state(values)
194
+ return {"auth": auth, "attached": bool(sha), "enabled": data.get("enabled", True) if data else False,
195
+ "path": project.MAPPING, "mapping": _ordered(data) if data else None, "mapping_sha256": sha,
196
+ "intrinsic_errors": errors, "steps": list(steps), "fields": fields,
197
+ "formats": sorted(mapping.FORMATS), "statuses": sorted(mapping.STATUSES),
198
+ "credentials": auth["signed_in"],
199
+ "default_spreadsheet": bool(values.get("default_spreadsheet_id")),
200
+ "spreadsheet_id": (data or {}).get("spreadsheet_id") or values.get("default_spreadsheet_id") or "",
201
+ "dry_run": bool(values.get("dry_run", False)), "comments_notice": COMMENTS_NOTICE,
202
+ "last_sync": {k: latest[k] for k in ("at", "result", "cells_written", "steps", "error_code") if k in latest}}
203
+
204
+
205
+ def sheet_info(root: Path, values: dict, payload: dict) -> dict:
206
+ sid = _sid(values, payload)
207
+ client = settings.make_engine(TIMEOUT, values=values).client
208
+ metadata = client.metadata(sid, deadline=time.monotonic() + TIMEOUT)
209
+ return {"spreadsheet_id": sid,
210
+ "title": metadata.get("properties", {}).get("title", ""),
211
+ "tabs": [{"title": s["properties"]["title"], "gid": s["properties"]["sheetId"]}
212
+ for s in metadata.get("sheets", [])]}
213
+
214
+
215
+ def headers(root: Path, values: dict, payload: dict) -> dict:
216
+ sid, row = _sid(values, payload), _header_row(payload)
217
+ client = settings.make_engine(TIMEOUT, values=values).client
218
+ deadline = time.monotonic() + TIMEOUT
219
+ title = _sheet(client, sid, payload, deadline)
220
+ rows = client.get(sid, f"{a1.quote(title)}!{row}:{row + 20}", deadline=deadline)
221
+ header = [mapping.text(v).strip() for v in (rows[0] if rows else [])]
222
+ key = payload.get("key_column")
223
+ if not key:
224
+ aliases = (project._execution(root).key_column, "TARGET_NAME", "target", "source", "name", "FILENAMES")
225
+ key = next((h for alias in aliases for h in header if h.casefold() == alias.casefold()), None)
226
+ col = a1.column(header, key) - 1 if key else None
227
+ sample = [mapping.text(r[col]) for r in rows[1:21] if col is not None and col < len(r)]
228
+ from .row_match import Resolver
229
+
230
+ mode = payload.get("match_against", "target")
231
+ code_column = payload.get("code_column", "")
232
+ code_col = a1.column(header, code_column) - 1 if code_column else None
233
+ resolver = Resolver(project.plan_rows(root), mode, with_code=bool(code_column))
234
+ matches = [resolver.sample(mapping.text(r[col]), mapping.text(r[code_col]) if code_col is not None and code_col < len(r) else "")
235
+ for r in rows[1:21] if col is not None and col < len(r)]
236
+ return {"matches": matches, "match_against": mode,
237
+ "headers": [{"letter": a1.letters(i), "name": name} for i, name in enumerate(header, 1)],
238
+ "sample_keys": sample, "key_column": key, "worksheet": title}
239
+
240
+
241
+ def validate(root: Path, values: dict, payload: dict) -> dict:
242
+ data = payload["mapping"] if "mapping" in payload else _read(root)[0]
243
+ if data is None:
244
+ return {"errors": [], "warnings": ["Sync is off: no alfrd.gsheet.yaml."]}
245
+ return _validation(root, values, data, live=True)
246
+
247
+
248
+ def save(root: Path, values: dict, payload: dict) -> dict:
249
+ sha = _revision(payload)
250
+ data = payload.get("mapping")
251
+ validation = _validation(root, values, data, live=True)
252
+ if validation["errors"]:
253
+ return {"saved": False, **validation}
254
+ new_sha = project.write_mapping(root, dump(_ordered(data)), base_sha256=sha, timeout=TIMEOUT)
255
+ return {"saved": True, "mapping_sha256": new_sha, **validation}
256
+
257
+
258
+ def attach(root: Path, values: dict, payload: dict) -> dict:
259
+ force = payload.get("force", False)
260
+ if type(force) is not bool:
261
+ raise mapping.MappingError("force must be true or false")
262
+ # Refuse before making a network call; write_mapping rechecks inside the lock.
263
+ if (root / project.MAPPING).exists() and not force:
264
+ raise mapping.MappingError("A mapping already exists; confirm replacement first.")
265
+ key = payload.get("key_column")
266
+ code = payload.get("code_column", "")
267
+ if not isinstance(key, str) or not key.strip():
268
+ raise mapping.MappingError("key_column must be a non-empty string")
269
+ if not isinstance(code, str):
270
+ raise mapping.MappingError("code_column must be a string")
271
+ sid = _sid(values, payload)
272
+ client = settings.make_engine(TIMEOUT, values=values).client
273
+ title = _sheet(client, sid, payload, time.monotonic() + TIMEOUT)
274
+ result = project.init_project(root, spreadsheet=payload.get("spreadsheet"), worksheet=title,
275
+ header_row=_header_row(payload), key_column=key, code_column=code,
276
+ force=force, values=values, timeout=TIMEOUT, match_against=payload.get("match_against", "target"))
277
+ return {"mapping": result.mapping, "mapping_sha256": result.mapping_sha256, "matched": list(result.matched)}
278
+
279
+
280
+ def detach(root: Path, values: dict, payload: dict) -> dict:
281
+ sha = _revision(payload)
282
+ mode = payload.get("mode")
283
+ if mode not in ("disable", "delete"):
284
+ raise mapping.MappingError("mode must be disable or delete")
285
+ if mode == "delete":
286
+ new_sha = project.write_mapping(root, None, base_sha256=sha, timeout=TIMEOUT)
287
+ return {"mapping_sha256": new_sha, "attached": False, "enabled": False}
288
+ data, current = _read(root)
289
+ if sha != current:
290
+ raise mapping.MappingError("The mapping changed on disk; reload.")
291
+ text = dump(_ordered({**data, "enabled": False})) if mode == "disable" and data is not None else None
292
+ new_sha = project.write_mapping(root, text, base_sha256=sha, timeout=TIMEOUT)
293
+ return {"mapping_sha256": new_sha, "attached": mode == "disable" and data is not None, "enabled": False}
294
+
295
+
296
+ def _push(root: Path, values: dict, payload: dict, *, preview_only: bool) -> dict:
297
+ for key in ("targets", "steps"):
298
+ if key in payload and (not isinstance(payload[key], list) or any(not isinstance(v, str) for v in payload[key])):
299
+ raise mapping.MappingError(f"{key} must be a list of strings")
300
+ if "plan" in payload and not isinstance(payload["plan"], str):
301
+ raise mapping.MappingError("plan must be a string")
302
+ outcome = project.push_project(root, targets=payload.get("targets"), steps=payload.get("steps"),
303
+ plan=payload.get("plan"), dry_run=preview_only, values=values,
304
+ timeout=BACKFILL_TIMEOUT)
305
+ result = outcome.result
306
+ changes = result.changes if result else {}
307
+ cells = [{"a1": address, **result.labels.get(address, {"target": "", "step": "", "column": ""}),
308
+ "old": result.previous.get(address, ""), "new": value}
309
+ for address, value in list(changes.items())[:500]]
310
+ return {"cells": cells, "total_cells": len(changes), "truncated": len(changes) > 500,
311
+ "conflicts": result.conflicts[:500] if result else [], "contexts": outcome.contexts,
312
+ "result": result.result if result else "no matching results",
313
+ "cells_written": result.cells_written if result else 0,
314
+ "dry_run": preview_only or bool(values.get("dry_run", False))}
315
+
316
+
317
+ def preview(root: Path, values: dict, payload: dict) -> dict:
318
+ return _push(root, values, payload, preview_only=True)
319
+
320
+
321
+ def backfill(root: Path, values: dict, payload: dict) -> dict:
322
+ if payload.get("confirm") is not True:
323
+ raise mapping.MappingError("Confirm the backfill before writing cells.")
324
+ return _push(root, values, payload, preview_only=False)
325
+
326
+
327
+ def _oauth_mode(values):
328
+ if settings.auth_mode(values) != "oauth":
329
+ raise SyncError("Choose oauth as the Sign-in method in Settings first.")
330
+
331
+
332
+ def auth_start(root, values, payload):
333
+ _oauth_mode(values)
334
+ cfg = oauth.require_config()
335
+ session = loopback.start("auth", cfg=cfg)
336
+ return {"session": session.id, "auth_url": oauth.authorization_url(cfg, session.redirect_uri, session.nonce, session.verifier),
337
+ "expires_in": session.expires_in}
338
+
339
+
340
+ def auth_status(root, values, payload):
341
+ _oauth_mode(values)
342
+ return loopback.get(payload.get("session"), "auth").status()
343
+
344
+
345
+ def auth_cancel(root, values, payload):
346
+ session = loopback.get(payload.get("session"), "auth")
347
+ session.finish("cancelled")
348
+ return session.status()
349
+
350
+
351
+ def sign_out(root, values, payload):
352
+ _oauth_mode(values)
353
+ if payload.get("confirm") is not True:
354
+ raise SyncError("Confirm sign-out: revocation signs out ALFRD on all computers for this Google project.")
355
+ return {"revoked": oauth.sign_out()}
356
+
357
+
358
+ def picker_start(root, values, payload):
359
+ _oauth_mode(values)
360
+ cfg = oauth.require_config(picker=True)
361
+ if payload.get("file_id") is not None and (not isinstance(payload["file_id"], str) or not loopback.ID.fullmatch(payload["file_id"])):
362
+ raise SyncError("Invalid spreadsheet id.")
363
+ token = oauth.access_token(deadline=time.monotonic() + TIMEOUT)
364
+ session = loopback.start("picker", cfg=cfg, token=token)
365
+ return {"session": session.id, "picker_url": session.origin + "/picker?n=" + session.nonce,
366
+ "expires_in": session.expires_in}
367
+
368
+
369
+ def picker_cancel(root, values, payload):
370
+ session = loopback.get(payload.get("session"), "picker")
371
+ session.finish("cancelled")
372
+ return session.status()
373
+
374
+
375
+ def picker_status(root, values, payload):
376
+ _oauth_mode(values)
377
+ session = loopback.get(payload.get("session"), "picker")
378
+ result = session.status()
379
+ if result["state"] == "picked":
380
+ with session.lock:
381
+ if "can_edit" not in session.file:
382
+ try:
383
+ client = settings.make_client(values)
384
+ session.file = client.drive_file(session.file["id"], deadline=time.monotonic() + TIMEOUT)
385
+ except SyncError:
386
+ session.state, session.message = "error", "The chosen sheet could not be opened. Choose it again or sign in again."
387
+ return session.status()
388
+ result["file"] = session.file
389
+ return result
390
+
391
+
392
+ def create_spreadsheet(root, values, payload):
393
+ return settings.make_client(values).create_spreadsheet(payload.get("title"), deadline=time.monotonic() + TIMEOUT)
394
+
395
+
396
+ def _redact(value: Any, values: dict) -> Any:
397
+ """Remove configured credential material even if a cell/error contains it."""
398
+ raw = values.get("credentials_json")
399
+ secrets = [raw] if isinstance(raw, str) and raw else []
400
+ try:
401
+ parsed = json.loads(raw or "{}")
402
+ if isinstance(parsed, dict):
403
+ secrets += [v for v in parsed.values() if isinstance(v, str) and v]
404
+ except (ValueError, TypeError):
405
+ pass
406
+
407
+ try:
408
+ secrets += [v for v in oauth.secret_values() if v]
409
+ except SyncError:
410
+ pass # config errors contain variable names only
411
+
412
+ def clean(item):
413
+ if isinstance(item, str):
414
+ for secret in secrets:
415
+ item = item.replace(secret, "<credentials>")
416
+ return item
417
+ if isinstance(item, dict):
418
+ return {clean(k): clean(v) for k, v in item.items()}
419
+ if isinstance(item, (list, tuple)):
420
+ return [clean(v) for v in item]
421
+ if item is None or isinstance(item, (bool, int, float)):
422
+ return item
423
+ return clean(mapping.text(item)) # malformed hand-edited YAML can contain dates/sets
424
+
425
+ return clean(value)
426
+
427
+
428
+ def _guard(run):
429
+ def guarded(root, values, payload):
430
+ try:
431
+ return _redact(run(Path(root).resolve(), values, payload), values)
432
+ except mapping.MappingError as exc:
433
+ raise ValueError(_redact(" ".join(str(exc).split())[:800], values)) from None
434
+ except SyncError as exc:
435
+ if run in (auth_start, auth_status, auth_cancel, sign_out, picker_start, picker_status, picker_cancel, create_spreadsheet) or exc.code in ("auth_required", "file_access"):
436
+ raise ValueError(_redact(" ".join(str(exc).split())[:800], values)) from None
437
+ raise ValueError("Google Sheet action failed (SyncError). Check the mapping and connection settings.") from None
438
+ except Exception as exc: # noqa: BLE001 - transport/auth text is untrusted, including ValueError
439
+ raise ValueError(f"Google Sheet action failed ({type(exc).__name__}). Check the mapping and connection settings.") from None
440
+ return guarded
441
+
442
+
443
+ ACTIONS = tuple(ProjectAction(name, _guard(run), mutating=mutating, timeout=timeout)
444
+ for name, run, mutating, timeout in (
445
+ ("auth_start", auth_start, True, 30), ("auth_status", auth_status, False, 30),
446
+ ("auth_cancel", auth_cancel, False, 10), ("sign_out", sign_out, True, 30),
447
+ ("picker_start", picker_start, False, 30), ("picker_status", picker_status, False, 30),
448
+ ("picker_cancel", picker_cancel, False, 10),
449
+ ("create_spreadsheet", create_spreadsheet, True, 60),
450
+ ("state", state, False, 30), ("sheet_info", sheet_info, False, 30),
451
+ ("headers", headers, False, 30), ("validate", validate, False, 30),
452
+ ("save", save, True, 60), ("attach", attach, True, 60), ("detach", detach, True, 30),
453
+ ("column_preview", columns.preview, False, 30), ("create_columns", columns.create, True, 60),
454
+ ("preview", preview, False, 300), ("backfill", backfill, True, 300)))