deepcell-cli 0.6.1__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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
deepcell_cli/output.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Output formatting helpers: JSON, table, and plain text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def print_json(data: Any) -> None:
|
|
13
|
+
"""Pretty-print *data* as JSON to stdout."""
|
|
14
|
+
click.echo(json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def print_plain(text: str) -> None:
|
|
18
|
+
"""Print plain text (markdown, XML, etc.) to stdout."""
|
|
19
|
+
click.echo(text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_table(rows: list[dict[str, Any]], columns: list[str] | None = None) -> None:
|
|
23
|
+
"""Render *rows* as a Rich table to the terminal.
|
|
24
|
+
|
|
25
|
+
If *columns* is ``None`` the keys of the first row are used.
|
|
26
|
+
"""
|
|
27
|
+
if not rows:
|
|
28
|
+
click.echo("(no results)")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
from rich.table import Table
|
|
33
|
+
|
|
34
|
+
# Some endpoints answer with a bare list of scalars (e.g. variant names).
|
|
35
|
+
# Wrap them so column derivation below doesn't call .keys() on a string.
|
|
36
|
+
if not isinstance(rows[0], dict):
|
|
37
|
+
rows = [{"value": r} for r in rows]
|
|
38
|
+
|
|
39
|
+
if columns is None:
|
|
40
|
+
columns = list(rows[0].keys())
|
|
41
|
+
|
|
42
|
+
table = Table(show_header=True, header_style="bold")
|
|
43
|
+
for col in columns:
|
|
44
|
+
table.add_column(col)
|
|
45
|
+
|
|
46
|
+
for row in rows:
|
|
47
|
+
table.add_row(*(str(row.get(c, "")) for c in columns))
|
|
48
|
+
|
|
49
|
+
Console().print(table)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _generated_id_lines(results: list[Any]) -> list[str]:
|
|
53
|
+
"""One line per server-generated id in an applied `add_*` op.
|
|
54
|
+
|
|
55
|
+
`defs add-sheet` promises in its own help that "the generated sheetId comes
|
|
56
|
+
back in this command's output". The endpoint does return it, in
|
|
57
|
+
`results[].details.sheetId` — the plain formatter rendered only the op
|
|
58
|
+
count and the revision, so it was dropped in transit and the docstring was
|
|
59
|
+
describing an output nobody could see.
|
|
60
|
+
|
|
61
|
+
The id matters more here than in most responses: it is minted by the
|
|
62
|
+
server from a uuid, so it cannot be guessed or reconstructed, and it is the
|
|
63
|
+
argument every follow-up command needs (`add-block --sheet`,
|
|
64
|
+
`add-slide --deck`). Without it the next step is a `cat` or a `describe` to
|
|
65
|
+
read back what was just written.
|
|
66
|
+
|
|
67
|
+
Restricted to `add_*` ops and to keys ending in `Id`: every other op echoes
|
|
68
|
+
the ids it was *given*, and reprinting those would bury the one new fact in
|
|
69
|
+
a list of things the caller already typed.
|
|
70
|
+
"""
|
|
71
|
+
lines: list[str] = []
|
|
72
|
+
for result in results:
|
|
73
|
+
if not isinstance(result, dict):
|
|
74
|
+
continue
|
|
75
|
+
kind = result.get("kind") or ""
|
|
76
|
+
if not (isinstance(kind, str) and kind.startswith("add_")):
|
|
77
|
+
continue
|
|
78
|
+
details = result.get("details")
|
|
79
|
+
if not isinstance(details, dict):
|
|
80
|
+
continue
|
|
81
|
+
minted = [
|
|
82
|
+
f"{k}={v}" for k, v in details.items()
|
|
83
|
+
if isinstance(k, str) and k.endswith("Id") and v
|
|
84
|
+
]
|
|
85
|
+
if minted:
|
|
86
|
+
lines.append(f" {kind}: {', '.join(minted)}")
|
|
87
|
+
return lines
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_plain_dict(d: dict[str, Any]) -> str:
|
|
91
|
+
"""Format a single dict concisely for plain output."""
|
|
92
|
+
# query value response: emit the value, or — when the cell resolved to no
|
|
93
|
+
# single value (empty, or populated under multiple statuses) — the per-status
|
|
94
|
+
# breakdown rather than a raw JSON blob.
|
|
95
|
+
if d.get("query_type") == "value" and isinstance(d.get("result"), dict):
|
|
96
|
+
res = d["result"]
|
|
97
|
+
val = res.get("value")
|
|
98
|
+
if val is not None:
|
|
99
|
+
return str(val)
|
|
100
|
+
vbs = res.get("values_by_status") or []
|
|
101
|
+
if vbs:
|
|
102
|
+
lines = (
|
|
103
|
+
["(value differs by status — specify a status)"] if len(vbs) > 1 else []
|
|
104
|
+
)
|
|
105
|
+
lines += [f"{e.get('status_ref', '')}: {e.get('value', '')}" for e in vbs]
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
return "(empty)"
|
|
108
|
+
|
|
109
|
+
# item_values response: context: value per line
|
|
110
|
+
if d.get("query_type") == "item_values" and isinstance(d.get("result"), list):
|
|
111
|
+
lines = [f"{r.get('context_ref', '')}: {r.get('value', '')}" for r in d["result"]]
|
|
112
|
+
truncated = d.get("truncated")
|
|
113
|
+
if truncated:
|
|
114
|
+
lines.append(f"... and {truncated} more")
|
|
115
|
+
return "\n".join(lines) if lines else "(no values)"
|
|
116
|
+
|
|
117
|
+
# edit/batch-edit and defs-ops response: summary + errors
|
|
118
|
+
if "results" in d and "errors" in d:
|
|
119
|
+
results = d["results"] if isinstance(d["results"], list) else []
|
|
120
|
+
n = len(results)
|
|
121
|
+
errs = d.get("errors", [])
|
|
122
|
+
# Defs-ops rows/errors carry `kind`; batch-edit rows carry cell refs.
|
|
123
|
+
is_ops = any(
|
|
124
|
+
isinstance(r, dict) and "kind" in r for r in results
|
|
125
|
+
) or any(isinstance(e, dict) and "kind" in e for e in errs)
|
|
126
|
+
noun = "op" if is_ops else "edit"
|
|
127
|
+
rev = d.get("revision", d.get("rev", ""))
|
|
128
|
+
rev_part = f" rev:{rev[:8]}" if rev else ""
|
|
129
|
+
if is_ops and d.get("success") is False:
|
|
130
|
+
# Whole-batch-atomic endpoints (/apply-defs-ops) return the
|
|
131
|
+
# in-memory successes in `results` even though the batch was
|
|
132
|
+
# discarded before persistence. Counting them as "applied" told
|
|
133
|
+
# the caller to retry only the failed op when the entire batch
|
|
134
|
+
# has to be re-sent.
|
|
135
|
+
#
|
|
136
|
+
# Gated on `is_ops`, NOT on `success` alone: /batch-edit sets
|
|
137
|
+
# `success = len(errors) == 0` and commits unconditionally
|
|
138
|
+
# (jingwei.py never reads it), so a partly-failed value edit landed
|
|
139
|
+
# here and told the caller "nothing was applied" about cells that
|
|
140
|
+
# are on disk — while stderr said the opposite and rev_part hid the
|
|
141
|
+
# revision that proves the write happened.
|
|
142
|
+
lines = [
|
|
143
|
+
f"batch rolled back: {n} {noun}(s) validated, "
|
|
144
|
+
f"{len(errs)} error(s) — nothing was applied"
|
|
145
|
+
]
|
|
146
|
+
else:
|
|
147
|
+
lines = [f"{n} {noun}(s) applied{rev_part}"]
|
|
148
|
+
lines.extend(_generated_id_lines(results))
|
|
149
|
+
# Show auto-created contexts
|
|
150
|
+
auto = d.get("auto_created_contexts", [])
|
|
151
|
+
if auto:
|
|
152
|
+
lines.append(f"auto-created contexts: {', '.join(auto)}")
|
|
153
|
+
# Formulas this edit removed. Reported louder than an auto-created
|
|
154
|
+
# context because it cannot be undone from the document — the
|
|
155
|
+
# definition is gone, and this line is the only place the formula text
|
|
156
|
+
# is still written down.
|
|
157
|
+
for rem in d.get("calc_removals", []) or []:
|
|
158
|
+
if not isinstance(rem, dict):
|
|
159
|
+
continue
|
|
160
|
+
action = rem.get("action") or "removed"
|
|
161
|
+
calc_id = rem.get("calcId", "?")
|
|
162
|
+
formula = rem.get("formula")
|
|
163
|
+
if action == "narrowed":
|
|
164
|
+
lines.append(
|
|
165
|
+
f" removed formula from {rem.get('contextRef', '?')}: "
|
|
166
|
+
f"{calc_id} no longer computes it "
|
|
167
|
+
f"({rem.get('remainingContexts', '?')} period(s) left)")
|
|
168
|
+
else:
|
|
169
|
+
lines.append(f" DELETED calculation {calc_id}")
|
|
170
|
+
if formula:
|
|
171
|
+
lines.append(f" was: {formula}")
|
|
172
|
+
# A narrow that ALSO took a definition out entirely: two elements
|
|
173
|
+
# answered to one id and one of them named only this period, so
|
|
174
|
+
# narrowing it left nothing. "narrowed" is the right word for what
|
|
175
|
+
# the id still does; it is not the whole bill.
|
|
176
|
+
for gone in rem.get("deletedFormulas") or []:
|
|
177
|
+
lines.append(f" also DELETED a definition of {calc_id}")
|
|
178
|
+
lines.append(f" was: {gone}")
|
|
179
|
+
# Show individual error details — cell-addressed for value edits,
|
|
180
|
+
# kind/code-addressed for defs ops (which have no itemRef/contextRef).
|
|
181
|
+
for e in errs:
|
|
182
|
+
if not isinstance(e, dict):
|
|
183
|
+
lines.append(f" error: {e}")
|
|
184
|
+
continue
|
|
185
|
+
reason = e.get("error") or e.get("message") or "unknown error"
|
|
186
|
+
if "itemRef" in e or "contextRef" in e:
|
|
187
|
+
lines.append(
|
|
188
|
+
f" error: {e.get('itemRef', '?')}[{e.get('contextRef', '?')}]: {reason}"
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
kind = e.get("kind") or e.get("code") or "error"
|
|
192
|
+
code_part = f" [{e['code']}]" if e.get("code") and e.get("kind") else ""
|
|
193
|
+
lines.append(f" error: {kind}{code_part}: {reason}")
|
|
194
|
+
# Show warnings (skip formula-mismatch noise)
|
|
195
|
+
for w in d.get("warnings", []):
|
|
196
|
+
if "mismatched values" in w:
|
|
197
|
+
continue
|
|
198
|
+
lines.append(f" warning: {w}")
|
|
199
|
+
return "\n".join(lines)
|
|
200
|
+
|
|
201
|
+
# flat dict (no nested dicts/lists): key: value per line
|
|
202
|
+
if all(not isinstance(v, (dict, list)) for v in d.values()):
|
|
203
|
+
return "\n".join(f"{k}: {v}" for k, v in d.items())
|
|
204
|
+
|
|
205
|
+
# complex nested: compact JSON (no indent)
|
|
206
|
+
return json.dumps(d, ensure_ascii=False, default=str)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _format_plain_list(rows: list[Any]) -> str:
|
|
210
|
+
"""Format a list concisely for plain output."""
|
|
211
|
+
if not rows:
|
|
212
|
+
return "(empty)"
|
|
213
|
+
|
|
214
|
+
# Non-dict items: one per line
|
|
215
|
+
if not isinstance(rows[0], dict):
|
|
216
|
+
return "\n".join(str(r) for r in rows)
|
|
217
|
+
|
|
218
|
+
keys = set(rows[0].keys())
|
|
219
|
+
|
|
220
|
+
# grep results: file:line:content
|
|
221
|
+
if {"file", "line", "content"} <= keys:
|
|
222
|
+
return "\n".join(
|
|
223
|
+
f"{r.get('file')}:{r.get('line')}:{r.get('content')}"
|
|
224
|
+
for r in rows
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# git log / version log: sha[:8] timestamp message
|
|
228
|
+
if "sha" in keys or "revision" in keys:
|
|
229
|
+
lines: list[str] = []
|
|
230
|
+
for r in rows:
|
|
231
|
+
sha = str(r.get("sha") or r.get("revision", ""))[:8]
|
|
232
|
+
# The versions endpoint names the field `timestamp`; older shapes
|
|
233
|
+
# used date/created_at.
|
|
234
|
+
date = r.get("timestamp") or r.get("date") or r.get("created_at", "")
|
|
235
|
+
msg = r.get("message", r.get("description", ""))
|
|
236
|
+
lines.append(f"{sha} {date} {msg}")
|
|
237
|
+
return "\n".join(lines)
|
|
238
|
+
|
|
239
|
+
# file listings: one filename per line
|
|
240
|
+
if "filename" in keys or (keys == {"name"}):
|
|
241
|
+
name_key = "filename" if "filename" in keys else "name"
|
|
242
|
+
return "\n".join(str(r.get(name_key, "")) for r in rows)
|
|
243
|
+
|
|
244
|
+
# workspace list: slug per line, * for active, role when known (it
|
|
245
|
+
# decides push/merge/share rights)
|
|
246
|
+
if "slug" in keys:
|
|
247
|
+
lines = []
|
|
248
|
+
for r in rows:
|
|
249
|
+
marker = " *" if r.get("active") in (True, "*") else ""
|
|
250
|
+
role = r.get("role")
|
|
251
|
+
role_part = f" {role}" if role else ""
|
|
252
|
+
lines.append(f"{r.get('slug')}{marker}{role_part}")
|
|
253
|
+
return "\n".join(lines)
|
|
254
|
+
|
|
255
|
+
# A row that carries an id ALONGSIDE a name: print both, id first.
|
|
256
|
+
#
|
|
257
|
+
# `ingest cn search` exists to turn a company name into a 6-digit code, and
|
|
258
|
+
# its rows are {code, name, org_id, exchange} — four keys, one of them
|
|
259
|
+
# `name`, so the small-dicts rule below printed the name and dropped the
|
|
260
|
+
# answer. The command echoed back what the caller had already typed.
|
|
261
|
+
#
|
|
262
|
+
# Generalised deliberately: whenever a row pairs a short identifier with a
|
|
263
|
+
# label, the identifier is the part the caller cannot type from memory and
|
|
264
|
+
# the part the next command takes as an argument.
|
|
265
|
+
if "code" in keys and "name" in keys:
|
|
266
|
+
lines = []
|
|
267
|
+
for r in rows:
|
|
268
|
+
exchange = r.get("exchange")
|
|
269
|
+
suffix = f" ({exchange})" if exchange and exchange != "?" else ""
|
|
270
|
+
lines.append(f"{r.get('code', '')} {r.get('name', '')}{suffix}")
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
# small dicts with name field
|
|
274
|
+
if "name" in keys and len(keys) <= 5:
|
|
275
|
+
return "\n".join(str(r.get("name", "")) for r in rows)
|
|
276
|
+
|
|
277
|
+
# fallback: first 3 scalar fields, tab-separated
|
|
278
|
+
cols = [k for k in rows[0] if not isinstance(rows[0][k], (dict, list))][:3]
|
|
279
|
+
if cols:
|
|
280
|
+
return "\n".join(
|
|
281
|
+
"\t".join(str(r.get(c, "")) for c in cols) for r in rows
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return json.dumps(rows, ensure_ascii=False, default=str)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _format_plain(data: Any) -> str:
|
|
288
|
+
"""Smart plain formatter: concise, token-saving output."""
|
|
289
|
+
if isinstance(data, str):
|
|
290
|
+
return data
|
|
291
|
+
if isinstance(data, list):
|
|
292
|
+
return _format_plain_list(data)
|
|
293
|
+
if isinstance(data, dict):
|
|
294
|
+
return _format_plain_dict(data)
|
|
295
|
+
return str(data)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def output(data: Any, fmt: str = "plain") -> None:
|
|
299
|
+
"""Dispatch to the right formatter.
|
|
300
|
+
|
|
301
|
+
*fmt* is one of ``json``, ``table``, ``plain``.
|
|
302
|
+
"""
|
|
303
|
+
if fmt == "plain":
|
|
304
|
+
print_plain(_format_plain(data))
|
|
305
|
+
elif fmt == "table":
|
|
306
|
+
if isinstance(data, list):
|
|
307
|
+
print_table(data)
|
|
308
|
+
elif isinstance(data, dict):
|
|
309
|
+
print_table([data])
|
|
310
|
+
else:
|
|
311
|
+
print_plain(str(data))
|
|
312
|
+
else:
|
|
313
|
+
print_json(data)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_SHA_LIKE_KEYS = frozenset({"revision", "rev", "commit_sha", "sha"})
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def output_mutation(data: Any, fmt: str, *, plain_key: str | None = "revision") -> None:
|
|
320
|
+
"""Emit a mutation response.
|
|
321
|
+
|
|
322
|
+
In ``plain`` mode, stdout receives only ``data[plain_key]`` — SHA-like values
|
|
323
|
+
are sliced to 8 chars. ``echo_success`` on stderr carries the human summary,
|
|
324
|
+
keeping stdout machine-friendly. In ``json`` / ``table`` mode, delegates to
|
|
325
|
+
``output()`` so the full structured payload is emitted unchanged.
|
|
326
|
+
"""
|
|
327
|
+
if fmt != "plain":
|
|
328
|
+
output(data, fmt)
|
|
329
|
+
return
|
|
330
|
+
if plain_key is None or not isinstance(data, dict):
|
|
331
|
+
return
|
|
332
|
+
val = data.get(plain_key)
|
|
333
|
+
if val is None:
|
|
334
|
+
return
|
|
335
|
+
s = str(val)
|
|
336
|
+
if plain_key in _SHA_LIKE_KEYS:
|
|
337
|
+
s = s[:8]
|
|
338
|
+
click.echo(s)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _export_notes(raw: Any) -> list[dict]:
|
|
342
|
+
"""Decode `X-Export-Notes` (base64 JSON list); [] on anything malformed."""
|
|
343
|
+
if not raw:
|
|
344
|
+
return []
|
|
345
|
+
try:
|
|
346
|
+
import base64
|
|
347
|
+
|
|
348
|
+
parsed = json.loads(base64.b64decode(str(raw)).decode("utf-8"))
|
|
349
|
+
except (ValueError, TypeError):
|
|
350
|
+
return []
|
|
351
|
+
return [n for n in parsed if isinstance(n, dict)] if isinstance(parsed, list) else []
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def echo_conversion_warnings(resp: Any) -> None:
|
|
355
|
+
"""Surface header-borne export warnings from a binary-export response.
|
|
356
|
+
|
|
357
|
+
``/to-excel`` and ``/to-pptx`` return the file as the body, so warnings
|
|
358
|
+
(failed recalc, chart fallbacks, …) ride in ``X-Conversion-Warnings``
|
|
359
|
+
(JSON list of messages, size-capped server-side) with the true total in
|
|
360
|
+
``X-Conversion-Warnings-Count``. ``X-PPTX-Rasterized-Regions`` counts
|
|
361
|
+
slide regions exported as images instead of editable objects.
|
|
362
|
+
"""
|
|
363
|
+
headers = getattr(resp, "headers", None) or {}
|
|
364
|
+
|
|
365
|
+
def _int_header(name: str) -> int:
|
|
366
|
+
try:
|
|
367
|
+
return int(headers.get(name, 0))
|
|
368
|
+
except (TypeError, ValueError):
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
count = _int_header("X-Conversion-Warnings-Count")
|
|
372
|
+
messages: list[Any] = []
|
|
373
|
+
raw = headers.get("X-Conversion-Warnings")
|
|
374
|
+
if raw:
|
|
375
|
+
try:
|
|
376
|
+
parsed = json.loads(raw)
|
|
377
|
+
if isinstance(parsed, list):
|
|
378
|
+
messages = parsed
|
|
379
|
+
except ValueError:
|
|
380
|
+
pass
|
|
381
|
+
# The structured notes (`X-Export-Notes`, base64 JSON), when the export
|
|
382
|
+
# sent them: grouped by slide and badged by fidelity, which is what a
|
|
383
|
+
# person deciding whether to ship the file wants to read. The string
|
|
384
|
+
# header stays for routes that have no notes (Excel, Word) and for a
|
|
385
|
+
# server that predates the header.
|
|
386
|
+
notes = _export_notes(headers.get("X-Export-Notes"))
|
|
387
|
+
if notes:
|
|
388
|
+
by_slide: dict[str, list[dict]] = {}
|
|
389
|
+
for note in notes:
|
|
390
|
+
where = str(note.get("slide_name") or note.get("slide_id") or "deck")
|
|
391
|
+
by_slide.setdefault(where, []).append(note)
|
|
392
|
+
for where, group in by_slide.items():
|
|
393
|
+
echo_warning(f"{where}:")
|
|
394
|
+
for note in group:
|
|
395
|
+
fidelity = str(note.get("fidelity") or "").strip()
|
|
396
|
+
badge = f"[{fidelity}] " if fidelity else ""
|
|
397
|
+
path = f"{note.get('path')} — " if note.get("path") else ""
|
|
398
|
+
echo_warning(f" {badge}{path}{note.get('message') or note.get('code')}")
|
|
399
|
+
# Route-level warnings (a failed recalc, a scenario that did not
|
|
400
|
+
# apply) ride only the string header; print the ones the notes did
|
|
401
|
+
# not cover, in front, because those decide whether the numbers are
|
|
402
|
+
# right at all.
|
|
403
|
+
covered = {str(n.get("message") or "") for n in notes}
|
|
404
|
+
for m in messages:
|
|
405
|
+
text = str(m)
|
|
406
|
+
if not any(c and c in text for c in covered):
|
|
407
|
+
echo_warning(text)
|
|
408
|
+
else:
|
|
409
|
+
for m in messages:
|
|
410
|
+
echo_warning(str(m))
|
|
411
|
+
remaining = count - len(messages)
|
|
412
|
+
if remaining > 0:
|
|
413
|
+
suffix = "" if messages else " during export"
|
|
414
|
+
echo_warning(
|
|
415
|
+
f"...and {remaining} more conversion warning(s){suffix} — "
|
|
416
|
+
"see the server's conversion-warning logs"
|
|
417
|
+
)
|
|
418
|
+
rasterized = _int_header("X-PPTX-Rasterized-Regions")
|
|
419
|
+
if rasterized:
|
|
420
|
+
echo_info(
|
|
421
|
+
f"{rasterized} slide region(s) were rasterized (unsupported CSS "
|
|
422
|
+
"exported as images, not editable objects)"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def echo_validation(data: Any) -> list[str]:
|
|
427
|
+
"""Surface a file-write response's ``validation`` block on stderr.
|
|
428
|
+
|
|
429
|
+
The server persists invalid .deepcell content on purpose (computed values
|
|
430
|
+
stay fresh; errors ride along in ``validation``), so a write can succeed
|
|
431
|
+
AND carry errors. Prints every warning and error, and returns the errors
|
|
432
|
+
so the caller can refuse to exit 0.
|
|
433
|
+
"""
|
|
434
|
+
if not isinstance(data, dict):
|
|
435
|
+
return []
|
|
436
|
+
validation = data.get("validation")
|
|
437
|
+
if not isinstance(validation, dict):
|
|
438
|
+
return []
|
|
439
|
+
for w in validation.get("warnings") or []:
|
|
440
|
+
echo_warning(str(w))
|
|
441
|
+
errors = [str(e) for e in validation.get("errors") or []]
|
|
442
|
+
for e in errors:
|
|
443
|
+
echo_error(f"Error: {e}")
|
|
444
|
+
return errors
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def echo_validation_map(validation: Any) -> list[str]:
|
|
448
|
+
"""Surface a ``{filename: validation}`` map on stderr.
|
|
449
|
+
|
|
450
|
+
Multi-file write paths (``files:batch`` push, variant merge-back) report
|
|
451
|
+
validation per file rather than as a single block. Returns the filenames
|
|
452
|
+
that carry errors so the caller can refuse to exit 0.
|
|
453
|
+
"""
|
|
454
|
+
invalid: list[str] = []
|
|
455
|
+
if not isinstance(validation, dict):
|
|
456
|
+
return invalid
|
|
457
|
+
for fname, info in validation.items():
|
|
458
|
+
if not isinstance(info, dict):
|
|
459
|
+
continue
|
|
460
|
+
for w in info.get("warnings") or []:
|
|
461
|
+
echo_warning(f"{fname}: {w}")
|
|
462
|
+
errors = info.get("errors") or []
|
|
463
|
+
for e in errors:
|
|
464
|
+
echo_error(f"{fname}: Error: {e}")
|
|
465
|
+
if errors:
|
|
466
|
+
invalid.append(str(fname))
|
|
467
|
+
return invalid
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def echo_success(message: str) -> None:
|
|
471
|
+
"""Print a success message to stderr (so stdout stays machine-readable)."""
|
|
472
|
+
click.echo(click.style(f"✓ {message}", fg="green"), err=True)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def echo_error(message: str) -> None:
|
|
476
|
+
"""Print an error message to stderr."""
|
|
477
|
+
click.echo(click.style(f"✗ {message}", fg="red"), err=True)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def echo_warning(message: str) -> None:
|
|
481
|
+
"""Print a warning message to stderr."""
|
|
482
|
+
click.echo(click.style(f"⚠ {message}", fg="yellow"), err=True)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def echo_info(message: str) -> None:
|
|
486
|
+
"""Print an informational message to stderr."""
|
|
487
|
+
click.echo(message, err=True)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def echo_query_back_hint(
|
|
491
|
+
filename: str, item: str | None = None, context: str | None = None
|
|
492
|
+
) -> None:
|
|
493
|
+
"""One-line R4 nudge after a value- or calc-affecting write.
|
|
494
|
+
|
|
495
|
+
R4 ("forecasts must populate — verify by query-back") is review-enforced,
|
|
496
|
+
so nothing in the command flow ever surfaced it: agents wrote values,
|
|
497
|
+
checked structure with describe/cat, and never read a number back. Emit
|
|
498
|
+
the exact query for the cell just touched at the one moment it is due.
|
|
499
|
+
"""
|
|
500
|
+
echo_info(
|
|
501
|
+
f"→ verify: deepcell query {filename} {item or '<item>'} "
|
|
502
|
+
f"{context or '<context>'} (rules R4: read the changed values back)"
|
|
503
|
+
)
|
deepcell_cli/revision.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Optimistic-lock (compare-and-swap) plumbing shared by the write commands.
|
|
2
|
+
|
|
3
|
+
Several commands are a fetch-XML → transform → write-back round trip:
|
|
4
|
+
Every ``reasoning add-*``/``update-*``,
|
|
5
|
+
``edit``, ``defs apply``, and ``replace``'s legacy fallback. Each one has a
|
|
6
|
+
lost-update window — whatever landed between the fetch and the write is
|
|
7
|
+
silently overwritten (#1217).
|
|
8
|
+
|
|
9
|
+
Closing it needs two things at every such site, which is why they live here
|
|
10
|
+
rather than being re-typed per command:
|
|
11
|
+
|
|
12
|
+
* the revision of the bytes that were fetched — a response *header*, so the
|
|
13
|
+
plain JSON body does not carry it (:meth:`APIClient.get_with_revision`);
|
|
14
|
+
* one rendering of the server's 409, so a conflict reads the same whichever
|
|
15
|
+
command hit it.
|
|
16
|
+
|
|
17
|
+
The second point carries a distinction worth keeping: a 409 means either that
|
|
18
|
+
the file itself moved (``stale_revision`` — the change was dropped and must be
|
|
19
|
+
re-applied by hand) or that only the workspace's commit lock was contended
|
|
20
|
+
(``concurrent_write`` — the file is untouched and re-running is safe). Handing
|
|
21
|
+
either message to the other caller is wrong, one as busywork and one as data
|
|
22
|
+
loss, so the reason is read from the server's ``X-Conflict-Reason`` header
|
|
23
|
+
rather than guessed from the wording of ``detail``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import click
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fetch_xml_with_revision(ctx, slug: str, filename: str) -> tuple[str, str | None]:
|
|
32
|
+
"""Read a workspace file's XML plus the revision it was read at.
|
|
33
|
+
|
|
34
|
+
A missing revision is not an error: an older server does not send the
|
|
35
|
+
header, and the caller then writes unguarded exactly as it did before.
|
|
36
|
+
"""
|
|
37
|
+
data, revision = ctx.client.get_with_revision(f"/workspaces/{slug}/files/{filename}")
|
|
38
|
+
xml = data.get("content", "") if isinstance(data, dict) else ""
|
|
39
|
+
if not xml:
|
|
40
|
+
raise click.ClickException(f"File '{filename}' is empty or not found.")
|
|
41
|
+
return xml, revision
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_body(content: str, revision: str | None, **extra) -> dict:
|
|
45
|
+
"""Build the workspace-write body, including the CAS token when we have one."""
|
|
46
|
+
body: dict = {"content": content}
|
|
47
|
+
if revision:
|
|
48
|
+
body["expected_revision"] = revision
|
|
49
|
+
body.update({k: v for k, v in extra.items() if v is not None})
|
|
50
|
+
return body
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Mirrors jingwei_api/conflict.py — the server's machine-readable answer to
|
|
54
|
+
# "why did this 409?". Keep the two in sync; they are one contract.
|
|
55
|
+
CONFLICT_REASON_HEADER = "x-conflict-reason"
|
|
56
|
+
CURRENT_REVISION_HEADER = "x-current-revision"
|
|
57
|
+
REASON_STALE_REVISION = "stale_revision"
|
|
58
|
+
REASON_CONCURRENT_WRITE = "concurrent_write"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _header(exc, name: str) -> str | None:
|
|
62
|
+
"""Case-insensitive header read off an :class:`APIError`.
|
|
63
|
+
|
|
64
|
+
httpx lowercases header names on the way in, but nothing guarantees the
|
|
65
|
+
caller built the error that way (tests construct them by hand), so the
|
|
66
|
+
lookup does not depend on it.
|
|
67
|
+
"""
|
|
68
|
+
headers = getattr(exc, "headers", None) or {}
|
|
69
|
+
for key, value in headers.items():
|
|
70
|
+
if key.lower() == name:
|
|
71
|
+
return value
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def conflict_reason(exc) -> str | None:
|
|
76
|
+
"""The server's reason for a 409, or None if this is not one we handle.
|
|
77
|
+
|
|
78
|
+
Two sources, because the write surfaces disagree on where they put it: the
|
|
79
|
+
``/workspaces/.../files`` family sends a structured ``detail`` body,
|
|
80
|
+
``/batch-edit`` and ``/apply-defs-ops`` send the header only. The header is
|
|
81
|
+
checked first — it is the one signal every surface now emits.
|
|
82
|
+
"""
|
|
83
|
+
if getattr(exc, "status_code", None) != 409:
|
|
84
|
+
return None
|
|
85
|
+
reason = _header(exc, CONFLICT_REASON_HEADER)
|
|
86
|
+
if reason:
|
|
87
|
+
return reason
|
|
88
|
+
payload = getattr(exc, "payload", None)
|
|
89
|
+
if isinstance(payload, dict):
|
|
90
|
+
return payload.get("reason")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_stale_revision(payload: dict, *, filename: str | None = None) -> str:
|
|
95
|
+
"""Render the server's ``stale_revision`` 409 into an actionable message."""
|
|
96
|
+
current = payload.get("current_revision")
|
|
97
|
+
who = f"'{filename}'" if filename else "The file"
|
|
98
|
+
lines = [
|
|
99
|
+
f"{who} changed since you read it"
|
|
100
|
+
+ (f" (current revision: {current})." if current else ".")
|
|
101
|
+
]
|
|
102
|
+
lines.append(
|
|
103
|
+
"Your change was NOT saved. Re-read the file and re-apply it — "
|
|
104
|
+
"replaying it onto the newer document would drop whoever wrote in "
|
|
105
|
+
"between."
|
|
106
|
+
)
|
|
107
|
+
hint = payload.get("hint")
|
|
108
|
+
if hint:
|
|
109
|
+
lines.append(f"Hint: {hint}")
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def format_concurrent_write(
|
|
114
|
+
current: str | None, *, filename: str | None = None
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Render a ``concurrent_write`` 409.
|
|
117
|
+
|
|
118
|
+
Deliberately a different message from :func:`format_stale_revision`, and
|
|
119
|
+
the difference is the whole point: here the file is byte-identical to what
|
|
120
|
+
the caller read and only the workspace's commit lock was contended, so
|
|
121
|
+
re-running the same command is correct rather than destructive. Telling
|
|
122
|
+
this caller to "re-read and re-apply" would be busywork, and telling the
|
|
123
|
+
stale caller to "just re-run" would silently drop someone's edit.
|
|
124
|
+
"""
|
|
125
|
+
who = f"'{filename}'" if filename else "The file"
|
|
126
|
+
lines = [
|
|
127
|
+
f"{who} was not written: the workspace is being committed to "
|
|
128
|
+
"concurrently."
|
|
129
|
+
]
|
|
130
|
+
lines.append(
|
|
131
|
+
"Nothing was lost and your file is unchanged — re-run the same command."
|
|
132
|
+
)
|
|
133
|
+
if current:
|
|
134
|
+
lines.append(f"(workspace is at revision {current})")
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def raise_if_stale(exc, *, filename: str | None = None) -> None:
|
|
139
|
+
"""Turn a write-conflict :class:`APIError` into a clean ``ClickException``.
|
|
140
|
+
|
|
141
|
+
Any other error is left alone for the caller to re-raise, so this never
|
|
142
|
+
swallows an unrelated 409 (``files:replace`` also 409s on *no match* and on
|
|
143
|
+
an *ambiguous* match).
|
|
144
|
+
"""
|
|
145
|
+
reason = conflict_reason(exc)
|
|
146
|
+
if reason == REASON_STALE_REVISION:
|
|
147
|
+
payload = getattr(exc, "payload", None)
|
|
148
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
149
|
+
# The header is the fallback for the surfaces that send no structured
|
|
150
|
+
# body, so the revision reaches the message either way.
|
|
151
|
+
if not payload.get("current_revision"):
|
|
152
|
+
payload = {
|
|
153
|
+
**payload,
|
|
154
|
+
"current_revision": _header(exc, CURRENT_REVISION_HEADER),
|
|
155
|
+
}
|
|
156
|
+
raise click.ClickException(
|
|
157
|
+
format_stale_revision(payload, filename=filename)
|
|
158
|
+
) from exc
|
|
159
|
+
if reason == REASON_CONCURRENT_WRITE:
|
|
160
|
+
raise click.ClickException(
|
|
161
|
+
format_concurrent_write(
|
|
162
|
+
_header(exc, CURRENT_REVISION_HEADER), filename=filename
|
|
163
|
+
)
|
|
164
|
+
) from exc
|