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.
Files changed (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. 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
+ )
@@ -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