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.
- alfrd_gsheet/__init__.py +33 -0
- alfrd_gsheet/a1.py +63 -0
- alfrd_gsheet/actions.py +454 -0
- alfrd_gsheet/cli.py +274 -0
- alfrd_gsheet/client.py +348 -0
- alfrd_gsheet/columns.py +94 -0
- alfrd_gsheet/loopback.py +214 -0
- alfrd_gsheet/mapping.py +380 -0
- alfrd_gsheet/oauth.py +362 -0
- alfrd_gsheet/oauth_client.json +1 -0
- alfrd_gsheet/pages/picker.html +35 -0
- alfrd_gsheet/pages/signed_in.html +2 -0
- alfrd_gsheet/project.py +260 -0
- alfrd_gsheet/row_match.py +43 -0
- alfrd_gsheet/settings.py +144 -0
- alfrd_gsheet/studio.py +119 -0
- alfrd_gsheet/sync.py +505 -0
- alfrd_gsheet/web/index.css +106 -0
- alfrd_gsheet/web/index.js +88 -0
- alfrd_gsheet/web/project_ui.js +959 -0
- alfrd_gsheet-0.2.0.dist-info/METADATA +530 -0
- alfrd_gsheet-0.2.0.dist-info/RECORD +25 -0
- alfrd_gsheet-0.2.0.dist-info/WHEEL +5 -0
- alfrd_gsheet-0.2.0.dist-info/entry_points.txt +2 -0
- alfrd_gsheet-0.2.0.dist-info/top_level.txt +1 -0
alfrd_gsheet/__init__.py
ADDED
|
@@ -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
|
alfrd_gsheet/actions.py
ADDED
|
@@ -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)))
|