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
@@ -0,0 +1,3890 @@
1
+ """Defs commands: structural edits (CalcDefs, ItemDefs, Periods, Scenarios, Statuses).
2
+
3
+ These commands let the agent define **relationships** — items, calculations,
4
+ periods, etc. — without supplying values. The backend recomputes every
5
+ CalculationDefinition on the next read, so a single `add-calc` is usually
6
+ all you need to forecast a derived metric.
7
+
8
+ For literal-value writes (assumptions, historical actuals), use `deepcell edit`.
9
+ For the paradigm overview, see `deepcell guide generate/calcs`.
10
+
11
+ NOTE: this subgroup mirrors the `/apply-defs-ops` discriminated union in
12
+ `backend/jingwei_api/routers/defs/`. When adding / renaming / removing op
13
+ kinds, update BOTH this file and the backend models in the same PR —
14
+ see the "Defs ops contract" section of CLAUDE.md.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from typing import Any
22
+
23
+ import click
24
+
25
+ from deepcell_cli.commands._datatypes import warn_unrecognized_data_type
26
+ from deepcell_cli.commands._write_opts import (
27
+ NegativeNumberWriteCommand,
28
+ WriteCommand,
29
+ write_message,
30
+ )
31
+
32
+ from deepcell_cli.commands._version_display import echo_version_history
33
+ from deepcell_cli.commands.describe import (
34
+ _format_status_diagnostics_warning,
35
+ _format_unrendered_values_warning,
36
+ )
37
+ from deepcell_cli.context import Ctx, pass_ctx
38
+ from deepcell_cli.output import (
39
+ echo_error,
40
+ echo_info,
41
+ echo_query_back_hint,
42
+ echo_success,
43
+ echo_warning,
44
+ output,
45
+ print_plain,
46
+ )
47
+
48
+
49
+
50
+ # Mirrors DefsOpRequest.ops max_length in
51
+ # backend/jingwei_api/routers/defs/__init__.py — keep in sync.
52
+ MAX_OPS_PER_BATCH = 100
53
+
54
+
55
+ def _warn_data_type(ctx, param, value):
56
+ """Click callback: flag an unrecognized --data-type without rejecting it.
57
+
58
+ Nothing else in the stack validates it — the item ops str() whatever they
59
+ are given — so a typo is stored verbatim and every formatter silently
60
+ falls back to its default.
61
+ """
62
+ message = warn_unrecognized_data_type(value)
63
+ if message:
64
+ echo_warning(message)
65
+ return value
66
+ _DRY_RUN_HELP = (
67
+ "Validate only: run the full server-side pipeline (per-op validation, "
68
+ "formula parse, post-apply cycle check + recompute) and report errors "
69
+ "without persisting anything — no commit, no revision bump."
70
+ )
71
+
72
+ #: Where ``_DefsCommand`` parks the parsed ``--dry-run`` so ``_apply`` can read
73
+ #: it without every subcommand callback growing a parameter for it.
74
+ _DRY_RUN_META_KEY = "deepcell.defs.dry_run"
75
+
76
+
77
+ class _DefsCommand(WriteCommand):
78
+ """A ``defs`` subcommand — every one of which accepts ``--dry-run``.
79
+
80
+ ``dry_run`` is a property of ``/apply-defs-ops``, not of any one op kind:
81
+ the service runs the complete pipeline against an in-memory document and
82
+ discards the result. Declared per-command it was on 25 of 47 subcommands
83
+ and the gap tracked nothing but who remembered to add it (#1217) — so a
84
+ caller could pre-flight a ``delete-calc`` but not the ``rename-item`` that
85
+ cascades across the whole document.
86
+
87
+ Injecting it here makes the flag a property of the *group*, which is what
88
+ it is on the wire, and means a subcommand added tomorrow inherits it. The
89
+ value is parked in ``ctx.meta`` rather than passed to the callback so the
90
+ 47 signatures stay unchanged; ``_apply`` reads it back.
91
+ """
92
+
93
+ def __init__(self, *args, **kwargs) -> None:
94
+ super().__init__(*args, **kwargs)
95
+ declared = {
96
+ opt
97
+ for param in self.params
98
+ if isinstance(param, click.Option)
99
+ for opt in param.opts
100
+ }
101
+ if "--dry-run" not in declared:
102
+ self.params.append(
103
+ click.Option(
104
+ ["--dry-run", "dry_run"],
105
+ is_flag=True,
106
+ default=False,
107
+ help=_DRY_RUN_HELP,
108
+ )
109
+ )
110
+ # ``-m`` / ``--title`` come from ``WriteCommand``, which is the same
111
+ # argument one layer along: every ``/apply-defs-ops`` call writes a
112
+ # commit and the endpoint has always accepted a message, so answering
113
+ # "No such option" read as "this edit does not make a commit" — the
114
+ # opposite of the truth. This family injected ``-m`` first and mapped
115
+ # it to the commit *subject*; it is the body everywhere else, so the
116
+ # shared class owns the mapping now and ``-m`` means the same thing
117
+ # whichever family you type it in.
118
+
119
+ def invoke(self, ctx: click.Context):
120
+ ctx.meta[_DRY_RUN_META_KEY] = bool(ctx.params.pop("dry_run", False))
121
+ return super().invoke(ctx)
122
+
123
+
124
+ class _DefsNegativeNumberCommand(_DefsCommand, NegativeNumberWriteCommand):
125
+ """``defs header set`` — takes a negative literal AND ``--dry-run``."""
126
+
127
+
128
+ class _DefsGroup(click.Group):
129
+ """The ``defs`` group and its subgroups, whose commands are all writes."""
130
+
131
+ command_class = _DefsCommand
132
+ #: Subgroups (``defs header``) are this class too, so their commands also
133
+ #: get the flag. `type` is Click's "same class as the parent" sentinel.
134
+ group_class = type
135
+
136
+
137
+ def _dry_run_requested() -> bool:
138
+ """Read the ``--dry-run`` parked by :class:`_DefsCommand` for this call."""
139
+ ctx = click.get_current_context(silent=True)
140
+ return bool(ctx.meta.get(_DRY_RUN_META_KEY)) if ctx is not None else False
141
+
142
+
143
+ # `blockType` is stored opaquely by `add_presentation_block` (deliberately
144
+ # user-extensible), but the render pipeline dispatches on a closed set and
145
+ # skips anything else with an UNSUPPORTED_BLOCK warning that never reaches the
146
+ # CLI caller. Mirrors the branches in
147
+ # backend/jingwei_api/utils/render_plan_builder.py::build_sheet_cells — keep in
148
+ # step with it.
149
+ _RENDERABLE_BLOCK_TYPES = frozenset(
150
+ {"table", "chart", "sensitivity", "key_value", "text"}
151
+ )
152
+
153
+ # Op kinds that change computed values (as opposed to structure or
154
+ # presentation). A successful batch containing one earns the R4 query-back
155
+ # hint — gated so presentation-only batches stay quiet and the hint keeps
156
+ # its signal.
157
+ _CALC_VALUE_OP_KINDS = frozenset({"add_calc", "update_calc", "delete_calc"})
158
+
159
+ #: How many of the plan's `conversion_warnings` (#1036) `defs apply` prints
160
+ #: before pointing at `describe` for the rest. See the call site for why.
161
+ _MAX_CONVERSION_WARNINGS_SHOWN = 3
162
+
163
+
164
+ def _echo_op_errors(errors: list[dict[str, Any]], *, total_ops: int) -> None:
165
+ """Print one line per failed op, addressed by its position in the batch.
166
+
167
+ The dispatcher has always returned ``index_in_request`` and the CLI has
168
+ always dropped it, so a 46-op batch reported `add_calc: <reason>` and left
169
+ the caller to find *which* ``add_calc`` — cheaper to re-send the batch
170
+ one op at a time than to read the error, which is exactly the round
171
+ economy this is meant to fix (eval job 01M1NFR73B5DMKW7ME9XGZ38HZ:
172
+ 282 CLI calls, 198 turns, 200 of them single-entity writes).
173
+
174
+ The closing line states the atomicity guarantee as a *fact the caller can
175
+ act on* rather than a warning: a rejected batch persisted nothing, so
176
+ pre-flighting every batch with ``--dry-run`` buys nothing a failed apply
177
+ does not already tell you — and it doubles the round count.
178
+ """
179
+ indexed = []
180
+ for e in errors:
181
+ idx = e.get("index_in_request")
182
+ kind = e.get("kind", "?")
183
+ reason = e.get("error", "unknown error")
184
+ code = e.get("code")
185
+ code_part = f" [{code}]" if code else ""
186
+ where = f"op {idx} ({kind})" if isinstance(idx, int) else kind
187
+ echo_error(f" {where}{code_part}: {reason}")
188
+ if isinstance(idx, int):
189
+ indexed.append(idx)
190
+
191
+ first = min(indexed) if indexed else None
192
+ if first is not None and total_ops > 1:
193
+ clean = (
194
+ f"ops 0-{first - 1} validated cleanly; " if first > 0 else ""
195
+ )
196
+ echo_error(
197
+ f" → {clean}op {first} is the first failure of {total_ops}. "
198
+ "Nothing was written, so this is safe to fix and re-send whole — "
199
+ "a --dry-run pre-flight would have reported the same thing at the "
200
+ "cost of an extra round."
201
+ )
202
+
203
+
204
+ def _apply(
205
+ ctx: Ctx,
206
+ slug: str,
207
+ filename: str,
208
+ ops: list[dict[str, Any]],
209
+ *,
210
+ revision: str | None = None,
211
+ dry_run: bool | None = None,
212
+ rationale: str | None = None,
213
+ title: str | None = None,
214
+ ) -> None:
215
+ """POST a batch of ops to /apply-defs-ops and emit the response.
216
+
217
+ Exits non-zero (via click.exceptions.Exit) when the API envelope
218
+ reports any op-level errors, so scripted callers can short-circuit
219
+ on `if deepcell defs ...; then ...`.
220
+
221
+ ``dry_run``, ``title`` and ``rationale`` default to whatever
222
+ ``--dry-run``, ``--title`` and ``-m/--message/--rationale`` the invoked
223
+ subcommand parsed (see :class:`_DefsCommand` and :class:`WriteCommand`);
224
+ pass any of them explicitly only to override. `doc` passes ``rationale``
225
+ that way, because its callbacks declare the option themselves.
226
+
227
+ This is the ONE place the `/apply-defs-ops` envelope is spelled. `deepcell
228
+ doc` built its own and drifted on every field that matters: `filename` as
229
+ `source_filename` (a 422 on every call) and `expected_revision` as
230
+ `revision` (pydantic ignores the extra, so the documented 409 never fired
231
+ and a concurrent write won silently). Route new callers through here.
232
+ """
233
+ if dry_run is None:
234
+ dry_run = _dry_run_requested()
235
+ parsed_title, parsed_rationale = write_message()
236
+ if title is None:
237
+ title = parsed_title
238
+ if rationale is None:
239
+ rationale = parsed_rationale
240
+ # Mirrors DefsOpRequest.ops max_length. Checked here so an over-cap batch
241
+ # says how to fix it instead of coming back as a raw pydantic 422 — the
242
+ # agent tool explicitly advises applying a wide grid "in ONE call", which
243
+ # walks straight into this limit.
244
+ if len(ops) > MAX_OPS_PER_BATCH:
245
+ raise click.ClickException(
246
+ f"{len(ops)} ops exceeds the server limit of {MAX_OPS_PER_BATCH} "
247
+ f"per request. Split them into batches of {MAX_OPS_PER_BATCH} and "
248
+ f"apply each in turn — each batch is applied atomically."
249
+ )
250
+
251
+ body: dict[str, Any] = {
252
+ "workspace_slug": slug,
253
+ "filename": filename,
254
+ "ops": ops,
255
+ }
256
+ if revision:
257
+ body["expected_revision"] = revision
258
+ if title:
259
+ body["title"] = title
260
+ if rationale:
261
+ body["rationale"] = rationale
262
+ if dry_run:
263
+ body["dry_run"] = True
264
+
265
+ from deepcell_cli.errors import APIError
266
+ from deepcell_cli.revision import raise_if_stale
267
+
268
+ try:
269
+ data = ctx.client.post("/apply-defs-ops", json=body) or {}
270
+ except APIError as exc:
271
+ # Same gap as `deepcell edit`: expected_revision is sent, the 409 was
272
+ # never rendered. See revision.raise_if_stale.
273
+ raise_if_stale(exc, filename=filename)
274
+ raise
275
+
276
+ if dry_run:
277
+ # Version-skew guard: a pre-Phase-4 Jingwei API silently drops the
278
+ # unknown ``dry_run`` field (pydantic ``extra='ignore'``) and performs
279
+ # a REAL apply — committing the batch. A genuine dry run guarantees a
280
+ # null ``revision``/``render_plan``/``version_history``; a non-null
281
+ # revision proves the server ignored the flag and persisted, so we must
282
+ # NOT print "nothing persisted". Fail loudly instead.
283
+ committed_rev = data.get("revision")
284
+ if committed_rev or data.get("render_plan") or data.get("version_history"):
285
+ echo_error(
286
+ "server ignored --dry-run (API too old?) — the batch WAS "
287
+ f"applied{f' at revision {committed_rev}' if committed_rev else ''}. "
288
+ "Upgrade the Jingwei API to a version that supports dry_run."
289
+ )
290
+ raise click.exceptions.Exit(1)
291
+
292
+ results = data.get("results") or []
293
+ errors = data.get("errors") or []
294
+ # `/apply-defs-ops` is whole-batch atomic: on any op error it returns
295
+ # success=False and returns BEFORE writing the file, so nothing persisted.
296
+ # `results` still carries the in-memory successes, and counting them
297
+ # reported "3 op(s) applied" for a batch that changed nothing — which reads
298
+ # as "retry only the failed one" when the whole batch has to be re-sent.
299
+ rolled_back = data.get("success") is False
300
+ n_ok = 0 if rolled_back else len(results)
301
+ n_err = len(errors)
302
+
303
+ if dry_run and ctx.fmt == "plain":
304
+ # The generic plain envelope formatter says "N edit(s) applied" —
305
+ # the wrong verb for a validation-only run where nothing persisted.
306
+ if n_err:
307
+ click.echo(f"dry run: {n_ok} op(s) validated, {n_err} error(s) — nothing persisted")
308
+ else:
309
+ click.echo(f"dry run: {n_ok} op(s) validated — nothing persisted")
310
+ else:
311
+ output(data, ctx.fmt)
312
+
313
+ verb = "validated" if dry_run else "applied"
314
+ if rolled_back:
315
+ echo_error(
316
+ f"batch rolled back — {n_err} error(s), nothing was applied "
317
+ f"(the whole batch must be re-sent, not just the failed op(s))"
318
+ )
319
+ _echo_op_errors(errors, total_ops=len(ops))
320
+ elif n_err:
321
+ echo_error(f"{n_ok} op(s) {verb}, {n_err} error(s)")
322
+ _echo_op_errors(errors, total_ops=len(ops))
323
+ elif ctx.fmt != "plain":
324
+ if dry_run:
325
+ echo_success(f"{n_ok} op(s) validated (dry run — nothing persisted)")
326
+ else:
327
+ echo_success(f"{n_ok} op(s) {verb}")
328
+
329
+ for w in data.get("warnings") or []:
330
+ echo_warning(w)
331
+
332
+ # The fill line for every deck slide this batch touched, measured by the
333
+ # export service on the write. Findings already printed above as
334
+ # warnings; this is the per-slide number, so a slide that fits says so.
335
+ for entry in data.get("fit") or []:
336
+ fill = entry.get("fill")
337
+ pct = f"{round(float(fill) * 100)}%" if isinstance(fill, (int, float)) else "?"
338
+ findings = entry.get("findings") or []
339
+ if findings:
340
+ n_error = sum(1 for f in findings if f.get("severity") == "error")
341
+ state = (
342
+ f"{n_error} error(s), {len(findings) - n_error} warning(s)"
343
+ if n_error else f"{len(findings)} warning(s)"
344
+ )
345
+ else:
346
+ state = "divider" if entry.get("divider") else "fits"
347
+ echo_info(f"Fit: {entry.get('deck_id')}/{entry.get('slide_id')} fill {pct} — {state}")
348
+
349
+ # Issue #1180 — literal-wins precedence means a calc does not fan out onto
350
+ # cells that already hold author literals. The backend reports them per
351
+ # add_calc op; without this warning the add looks like a clean success and
352
+ # the blocked cells never recompute.
353
+ for r in results:
354
+ details = r.get("details") if isinstance(r, dict) else None
355
+ shadowed = details.get("shadowed_cells") if isinstance(details, dict) else None
356
+ if shadowed:
357
+ item_ref = details.get("itemRef", "?")
358
+ first = shadowed[0]
359
+ shown = ", ".join(c.get("contextRef") or "?" for c in shadowed[:5])
360
+ more = f", … +{len(shadowed) - 5} more" if len(shadowed) > 5 else ""
361
+ # The --clear example must target the exact cell — a status- or
362
+ # scenario-tagged literal cleared without its flags removes the
363
+ # wrong (base) cell (#1185 review finding 4).
364
+ flags = ""
365
+ if first.get("statusRef"):
366
+ flags += f" --status {first['statusRef']}"
367
+ if first.get("scenarioRef"):
368
+ flags += f" --scenario {first['scenarioRef']}"
369
+ echo_warning(
370
+ f"{len(shadowed)} literal cell(s) shadow the new calc on "
371
+ f"'{item_ref}' ({shown}{more}) and will NOT recompute — clear "
372
+ f"them to let the formula govern, e.g. `deepcell edit "
373
+ f"{filename} {item_ref} {first.get('contextRef')} --clear{flags}`"
374
+ )
375
+
376
+ # A4 — `--status` is documented as strongly recommended but not required
377
+ # by Click, so an untagged forecast calc lands silently beside tagged
378
+ # actuals and answers reads meant for them. Warn where that has happened.
379
+ for r in results:
380
+ details = r.get("details") if isinstance(r, dict) else None
381
+ joined = (
382
+ details.get("untagged_calc_joins_statuses")
383
+ if isinstance(details, dict) else None
384
+ )
385
+ if joined:
386
+ item_ref = details.get("itemRef", "?")
387
+ echo_warning(
388
+ f"the new calc on '{item_ref}' is untagged, but that slot "
389
+ f"already holds {', '.join(joined)} — an untagged cell answers "
390
+ f"reads for every status, so the two will compete for one "
391
+ f"coordinate. Re-add with `--status <id>` to keep them apart."
392
+ )
393
+
394
+ # Destructive ops report what they took with them in `details`, but the
395
+ # plain envelope prints only "N op(s) applied" — a cascade that purged an
396
+ # item subtree and 84 value cells read exactly like a no-op rename.
397
+ # A rolled-back batch deleted nothing, so announcing "84 cells were
398
+ # deleted — restore them from history" would send the user to undo work
399
+ # that is still intact.
400
+ for r in ([] if rolled_back else results):
401
+ details = r.get("details") if isinstance(r, dict) else None
402
+ if not isinstance(details, dict):
403
+ continue
404
+ target = (
405
+ details.get("itemId")
406
+ or details.get("contextId")
407
+ or details.get("scenarioContextRef")
408
+ or "?"
409
+ )
410
+ removed_ids = details.get("removed") or []
411
+ if len(removed_ids) > 1:
412
+ echo_warning(
413
+ f"Removed {len(removed_ids)} item(s) with '{target}': "
414
+ + ", ".join(str(i) for i in removed_ids)
415
+ )
416
+ values_removed = details.get("values_removed") or 0
417
+ if values_removed:
418
+ echo_warning(
419
+ f"{values_removed} value cell(s) were deleted with '{target}' — "
420
+ f"restore them from history if this was not intended "
421
+ f"(`deepcell log` / `deepcell restore`)"
422
+ )
423
+ # An overwrite-shape calc reports `replaced_value` with no
424
+ # `shadowed_cells`: the authored literal is silently OVERWRITTEN by
425
+ # the recompute rather than shadowing the calc. Storage form does not
426
+ # enter into it — a flat <Value> literal used to survive and be
427
+ # reported as shadowed, but #1428 made a recompute address the
428
+ # coordinate rather than the container.
429
+ if details.get("replaced_value") is not None and not details.get("shadowed_cells"):
430
+ echo_warning(
431
+ f"Authored literal {details['replaced_value']!s} on "
432
+ f"'{details.get('itemRef', target)}' will be overwritten by the "
433
+ f"new formula's recompute"
434
+ )
435
+
436
+ # Issue #620 — structural ops are the most common source of orphans
437
+ # (an item added without being placed on a block, a context not in any
438
+ # block's contextRefs). `/apply-defs-ops` returns a fresh RenderPlan;
439
+ # surface its `unrendered_values` here so the user catches the
440
+ # regression without a follow-up `deepcell lint`.
441
+ plan = data.get("render_plan") if isinstance(data, dict) else None
442
+ orphans = plan.get("unrendered_values") if isinstance(plan, dict) else None
443
+ if orphans:
444
+ click.echo(_format_unrendered_values_warning(orphans), err=True)
445
+
446
+ # Issue #1371 — `status_diagnostics` is in that SAME dict, fetched by that
447
+ # same line, and used to be dropped. The #620 rationale applies verbatim:
448
+ # `defs apply` is exactly where a user creates a status clash
449
+ # (`defs add-calc --status`, `defs update-context --status`), so it is the
450
+ # moment the warning is due. Before this, the command that CAUSED the clash
451
+ # said nothing and the only surface that mentioned it was a follow-up
452
+ # `describe`, in prose, under a heading explicitly marked informational.
453
+ diagnostics = plan.get("status_diagnostics") if isinstance(plan, dict) else None
454
+ if diagnostics:
455
+ click.echo(_format_status_diagnostics_warning(diagnostics), err=True)
456
+
457
+ # Issue #1036 — what the builder had to coerce to render the post-edit
458
+ # document (an out-of-enum @chartType clamped to "bar"). Structural ops are
459
+ # how those attributes get written, so this
460
+ # is also where a typo in one first becomes visible; it previously reached
461
+ # the server log and nowhere else.
462
+ #
463
+ # Capped at three. The demo corpus has a document with 88 of these
464
+ # (`ichiban-ramen-advanced`), and 88 lines after every `defs` command is
465
+ # noise a user learns to scroll past — which costs the signal the whole
466
+ # change exists to deliver. `describe` prints the full list under "Export
467
+ # notes", so the tail has somewhere to be read.
468
+ coerced = [
469
+ w for w in (
470
+ plan.get("conversion_warnings") if isinstance(plan, dict) else None
471
+ ) or []
472
+ if isinstance(w, dict) and w.get("message")
473
+ ]
474
+ for warning in coerced[:_MAX_CONVERSION_WARNINGS_SHOWN]:
475
+ echo_warning(str(warning["message"]))
476
+ if len(coerced) > _MAX_CONVERSION_WARNINGS_SHOWN:
477
+ echo_warning(
478
+ f"…and {len(coerced) - _MAX_CONVERSION_WARNINGS_SHOWN} more "
479
+ f"conversion warning(s) — run `deepcell describe {filename}` "
480
+ "for the full list"
481
+ )
482
+
483
+ # Eval S1 / R4: a persisted batch that touched calcs changed computed
484
+ # values — surface the query-back at the moment it is due, with the
485
+ # calc's own coordinates when the ops carry them.
486
+ if not dry_run and not rolled_back and not n_err:
487
+ calc_ops = [o for o in ops if o.get("kind") in _CALC_VALUE_OP_KINDS]
488
+ if calc_ops:
489
+ item = context = None
490
+ for o in calc_ops:
491
+ defaults = o.get("defaults") or {}
492
+ item = item or defaults.get("itemId")
493
+ raw_ctx = defaults.get("contextRef")
494
+ if not context and isinstance(raw_ctx, str) and raw_ctx.strip():
495
+ context = raw_ctx.split(",")[0].strip()
496
+ echo_query_back_hint(filename, item=item, context=context)
497
+
498
+ echo_version_history(data)
499
+
500
+ if n_err:
501
+ raise click.exceptions.Exit(1)
502
+
503
+
504
+ # ── Group ───────────────────────────────────────────────────
505
+
506
+
507
+ @click.group(cls=_DefsGroup)
508
+ def defs() -> None:
509
+ """Structural edits: define items, calculations, periods, scenarios.
510
+
511
+ \b
512
+ BUILDING MORE THAN A FEW DEFS? Use `defs apply`. Every add-* below is one
513
+ round-trip that answers in a couple of hundred bytes; `defs apply` sends up
514
+ to 100 of them in a single call, atomically, and it accepts the reasoning
515
+ op kinds too — so structure and argument can land together:
516
+ deepcell defs apply model.deepcell --ops '[{"kind": "add_item", ...}, ...]'
517
+ The single add-* commands are for a one-off edit, and for finding out what
518
+ fields an op takes without writing JSON.
519
+
520
+ \b
521
+ For literal values: deepcell edit FILE ITEM CTX VALUE
522
+ For paradigm: deepcell guide generate/calcs
523
+ For presentations: deepcell guide present/layout
524
+ For HTML decks: deepcell guide present/decks
525
+
526
+ \b
527
+ Examples:
528
+ deepcell defs add-calc model.deepcell --item Revenue \\
529
+ --formula "Revenue[PREVIOUS] * (1 + Revenue_Growth[CURRENT])"
530
+ deepcell defs add-item model.deepcell --name Gross_Margin --level 2
531
+ deepcell defs add-context model.deepcell --name FY2027E --status projected --state future
532
+ deepcell defs apply model.deepcell --ops-file ops.json
533
+
534
+ \b
535
+ Reads:
536
+ deepcell defs list model.deepcell # what is defined, by kind
537
+ deepcell defs show model.deepcell Revenue # one item + its CalcDefs
538
+ """
539
+
540
+
541
+ # ── Reads ───────────────────────────────────────────────────
542
+ #
543
+ # Every other subcommand here is a mutation, and the reads live under other
544
+ # nouns (`describe` / `query` / `cat`) — so a fresh session reached for the
545
+ # CRUD-symmetric `defs list` / `defs show` and got "No such command" (#1291).
546
+ # `cls=click.Command` on purpose: these are the only defs subcommands that must
547
+ # NOT inherit `--dry-run` from `_DefsCommand`, because they write nothing.
548
+
549
+
550
+ def _plain_kv(rows: list[dict], id_field: str, *extra_fields: str) -> str:
551
+ """Render def rows as one line each: the id, then whatever else is set."""
552
+ lines = []
553
+ for row in rows:
554
+ extras = [
555
+ f"{key}={row[key]}"
556
+ for key in extra_fields
557
+ if row.get(key) not in (None, "", False)
558
+ ]
559
+ lines.append(
560
+ f" {row.get(id_field) or '?'}"
561
+ + (f" ({', '.join(extras)})" if extras else "")
562
+ )
563
+ return "\n".join(lines)
564
+
565
+
566
+ @defs.command("list", cls=click.Command)
567
+ @click.argument("filename")
568
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
569
+ @pass_ctx
570
+ def list_defs(ctx: Ctx, filename: str, workspace_slug: str | None) -> None:
571
+ """List what the document DEFINES: items, calcs, contexts, scenarios, statuses.
572
+
573
+ Definitions and ids only — no values. For values use `deepcell query`; for
574
+ the presentation shape (sheets, blocks) and lint findings use `deepcell
575
+ describe`; for the raw XML use `deepcell cat`.
576
+
577
+ This is the only surface that reports CalcDefs with their `calcId` — the id
578
+ `defs delete-calc` / `defs update-calc` and `reasoning add-claim
579
+ --calc-ref` must quote.
580
+ """
581
+ slug = workspace_slug or ctx.require_workspace()
582
+ data = ctx.client.post(
583
+ "/defs/list", json={"workspace_slug": slug, "filename": filename}
584
+ )
585
+ if ctx.fmt != "plain" or not isinstance(data, dict):
586
+ output(data, ctx.fmt)
587
+ return
588
+
589
+ counts = data.get("counts") or {}
590
+ lines = [
591
+ f"Defs in {data.get('filename') or filename}: "
592
+ + ", ".join(f"{n} {kind}" for kind, n in counts.items())
593
+ ]
594
+ sections = (
595
+ ("items", ("item_id", "label", "level", "parent_item", "data_type")),
596
+ ("calculations",
597
+ ("calc_id", "item_ref", "formula", "context_refs", "status_ref",
598
+ "scenario_ref")),
599
+ ("contexts", ("context_id", "label", "status_ref")),
600
+ ("scenarios", ("scenario_id", "label", "is_default")),
601
+ ("statuses", ("status_id", "label", "is_default")),
602
+ )
603
+ for kind, fields in sections:
604
+ rows = data.get(kind) or []
605
+ if not rows:
606
+ continue
607
+ lines += ["", f"{kind.capitalize()} ({counts.get(kind, len(rows))}):",
608
+ _plain_kv(rows, *fields)]
609
+ if data.get("note"):
610
+ lines += ["", str(data["note"])]
611
+ print_plain("\n".join(lines))
612
+
613
+
614
+ @defs.command("show", cls=click.Command)
615
+ @click.argument("filename")
616
+ @click.argument("item_id")
617
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
618
+ @pass_ctx
619
+ def show_def(
620
+ ctx: Ctx, filename: str, item_id: str, workspace_slug: str | None
621
+ ) -> None:
622
+ """Show one item's ItemDef and every CalcDef that governs it.
623
+
624
+ Exits non-zero on an unknown itemId — the error carries did-you-mean
625
+ suggestions rather than an empty-looking success.
626
+ """
627
+ slug = workspace_slug or ctx.require_workspace()
628
+ data = ctx.client.post(
629
+ "/defs/show",
630
+ json={"workspace_slug": slug, "filename": filename, "item_id": item_id},
631
+ )
632
+ if ctx.fmt != "plain" or not isinstance(data, dict):
633
+ output(data, ctx.fmt)
634
+ return
635
+
636
+ item = data.get("item") or {}
637
+ lines = [f"Item: {item.get('item_id')}"]
638
+ for key in ("label", "level", "order", "parent_item", "data_type",
639
+ "scale", "currency", "unit"):
640
+ if item.get(key) not in (None, ""):
641
+ lines.append(f" {key}: {item[key]}")
642
+ children = data.get("children") or []
643
+ if children:
644
+ lines.append(f" children: {', '.join(map(str, children))}")
645
+
646
+ calcs = data.get("calculations") or []
647
+ lines.append("")
648
+ if calcs:
649
+ # Source order is load-bearing: on a specificity tie the first calc
650
+ # wins (`deepcell ref op/add_calc` has the rule).
651
+ lines.append(f"CalcDefs governing it ({len(calcs)}, in source order):")
652
+ lines.append(_plain_kv(
653
+ calcs, "calc_id", "formula", "context_refs", "status_ref",
654
+ "scenario_ref",
655
+ ))
656
+ else:
657
+ lines.append(
658
+ "CalcDefs governing it: none — this item holds literal values only."
659
+ )
660
+ print_plain("\n".join(lines))
661
+
662
+
663
+ # ── CalcDef ops ─────────────────────────────────────────────
664
+
665
+
666
+ @defs.command("add-calc")
667
+ @click.argument("filename")
668
+ @click.option("--item", "item_id", required=True, help="Item the calc resolves into (itemId).")
669
+ @click.option(
670
+ "--calc-id",
671
+ "calc_id",
672
+ default=None,
673
+ help=(
674
+ "Stable calcId for the new calc (e.g. 'calc_stress_low'). Errors if "
675
+ "the id is already taken. Omit to let the server assign one — but an "
676
+ "auto id is what `defs delete-calc` and `reasoning add-claim "
677
+ "--calc-ref` must then quote, so choose your own when the calc has to "
678
+ "be citable."
679
+ ),
680
+ )
681
+ @click.option("--formula", required=True, help="Jingwei formula, e.g. \"Revenue[PREVIOUS] * 1.1\".")
682
+ @click.option(
683
+ "--context",
684
+ "context_ref",
685
+ default=None,
686
+ help=(
687
+ "Pin calc to one or more contextRefs. Pass a CSV (e.g. "
688
+ "'FY25,FY26,FY27') to pin a roll-forward chain to several periods in "
689
+ "one call — omit the seed period so its literal is kept. Omit entirely "
690
+ "to fan out across every period the item lacks a literal value."
691
+ ),
692
+ )
693
+ @click.option("--scenario", "scenario_ref", default=None, help="Limit calc to this scenarioRef.")
694
+ @click.option(
695
+ "--status",
696
+ "status_ref",
697
+ default=None,
698
+ help=(
699
+ "Pin calc to a statusRef (e.g. 'projected'). Strongly recommended for "
700
+ "forecast formulas so they coexist with the historical actuals in the "
701
+ "same item/context slots — without it the projection stores one "
702
+ "untagged cell that answers every status, leaving the actuals nowhere "
703
+ "to sit. Not enforced: omitting it succeeds, and the cost shows up "
704
+ "later as an actual you cannot store."
705
+ ),
706
+ )
707
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
708
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
709
+ @pass_ctx
710
+ def add_calc(
711
+ ctx: Ctx,
712
+ filename: str,
713
+ item_id: str,
714
+ calc_id: str | None,
715
+ formula: str,
716
+ context_ref: str | None,
717
+ scenario_ref: str | None,
718
+ status_ref: str | None,
719
+ revision: str | None,
720
+ workspace_slug: str | None,
721
+ ) -> None:
722
+ """Add a CalculationDefinition (formula). Backend computes the cell.
723
+
724
+ Pass --dry-run to pre-flight the formula (parse + cycle check) without
725
+ writing anything.
726
+ """
727
+ slug = workspace_slug or ctx.require_workspace()
728
+ defaults: dict[str, Any] = {"itemId": item_id, "formula": formula}
729
+ # Key matches CalcDefIntent.calcId (docs/backend-cli-contract.md). Omitted
730
+ # entirely when unset so the server keeps assigning the id.
731
+ if calc_id is not None:
732
+ defaults["calcId"] = calc_id
733
+ if context_ref is not None:
734
+ defaults["contextRef"] = context_ref
735
+ if scenario_ref is not None:
736
+ defaults["scenarioRef"] = scenario_ref
737
+ if status_ref is not None:
738
+ defaults["statusRef"] = status_ref
739
+ op = {"kind": "add_calc", "defaults": defaults}
740
+ _apply(ctx, slug, filename, [op], revision=revision)
741
+
742
+
743
+ @defs.command("update-calc")
744
+ @click.argument("filename")
745
+ @click.argument("calc_id")
746
+ @click.option("--formula", default=None, help="Replace the formula.")
747
+ @click.option("--context", "context_ref", default=None, help="Change contextRef.")
748
+ @click.option("--scenario", "scenario_ref", default=None, help="Change scenarioRef.")
749
+ @click.option("--status", "status_ref", default=None, help="Change statusRef (e.g. 'projected'). Pass '' to clear.")
750
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
751
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
752
+ @pass_ctx
753
+ def update_calc(
754
+ ctx: Ctx,
755
+ filename: str,
756
+ calc_id: str,
757
+ formula: str | None,
758
+ context_ref: str | None,
759
+ scenario_ref: str | None,
760
+ status_ref: str | None,
761
+ revision: str | None,
762
+ workspace_slug: str | None,
763
+ ) -> None:
764
+ """Patch a CalcDef; only the fields you pass are changed.
765
+
766
+ Pass --dry-run to pre-flight the patch (parse + cycle check) without
767
+ writing anything.
768
+ """
769
+ slug = workspace_slug or ctx.require_workspace()
770
+ patch: dict[str, Any] = {}
771
+ if formula is not None:
772
+ patch["formula"] = formula
773
+ if context_ref is not None:
774
+ patch["contextRef"] = context_ref
775
+ if scenario_ref is not None:
776
+ patch["scenarioRef"] = scenario_ref
777
+ if status_ref is not None:
778
+ patch["statusRef"] = status_ref
779
+ if not patch:
780
+ raise click.UsageError("Provide at least one of --formula / --context / --scenario / --status.")
781
+ op = {"kind": "update_calc", "calcId": calc_id, "patch": patch}
782
+ _apply(ctx, slug, filename, [op], revision=revision)
783
+
784
+
785
+ @defs.command("delete-calc")
786
+ @click.argument("filename")
787
+ @click.argument("calc_id")
788
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
789
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
790
+ @pass_ctx
791
+ def delete_calc(
792
+ ctx: Ctx,
793
+ filename: str,
794
+ calc_id: str,
795
+ revision: str | None,
796
+ workspace_slug: str | None,
797
+ ) -> None:
798
+ """Delete a CalculationDefinition by calcId."""
799
+ slug = workspace_slug or ctx.require_workspace()
800
+ op = {"kind": "delete_calc", "calcId": calc_id}
801
+ _apply(ctx, slug, filename, [op], revision=revision)
802
+
803
+
804
+ # ── Item ops ────────────────────────────────────────────────
805
+
806
+
807
+ @defs.command("add-item")
808
+ @click.argument("filename")
809
+ @click.option("--name", required=True, help="Item name (itemId basis).")
810
+ @click.option(
811
+ "--label",
812
+ default=None,
813
+ help="Display label (e.g. 'Gross Margin %'); defaults to a humanized form "
814
+ "of --name. Written as one <Label lang=\"en\">: no typed op adds a "
815
+ "second locale, so a bilingual item (en + zh) is seeded in the "
816
+ "initial `deepcell write`.",
817
+ )
818
+ @click.option("--level", type=int, default=None, help="Hierarchy level (0-3).")
819
+ @click.option("--parent", "parent_item_id", default=None, help="Parent itemId (omit for root).")
820
+ @click.option("--index", type=int, default=None, help="Position among siblings (omit to append at end).")
821
+ @click.option(
822
+ "--order",
823
+ type=int,
824
+ default=None,
825
+ help="Explicit @order (must be unique; presentation blocks address rows by order ranges). Overrides --index placement.",
826
+ )
827
+ @click.option(
828
+ "--order-mode",
829
+ "order_mode",
830
+ type=click.Choice(["append"]),
831
+ default=None,
832
+ help="'append' assigns max(existing orders) + 10 — no need to know which orders are taken. Mutually exclusive with --order; overrides --index.",
833
+ )
834
+ @click.option(
835
+ "--data-type",
836
+ "data_type",
837
+ default=None,
838
+ callback=_warn_data_type,
839
+ help="DataType element (e.g. 'monetary', 'percentage', 'number').",
840
+ )
841
+ @click.option("--unit", default=None, help="Unit element (e.g. '%', 'hours', 'tonnes').")
842
+ @click.option("--scale", type=int, default=None, help="Scale element (units multiplier exponent).")
843
+ @click.option("--currency", default=None, help="Currency element (ISO code, e.g. 'USD').")
844
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
845
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
846
+ @pass_ctx
847
+ def add_item(
848
+ ctx: Ctx,
849
+ filename: str,
850
+ name: str,
851
+ label: str | None,
852
+ level: int | None,
853
+ parent_item_id: str | None,
854
+ index: int | None,
855
+ order: int | None,
856
+ order_mode: str | None,
857
+ data_type: str | None,
858
+ unit: str | None,
859
+ scale: int | None,
860
+ currency: str | None,
861
+ revision: str | None,
862
+ workspace_slug: str | None,
863
+ ) -> None:
864
+ """Add an ItemDefinition (no value)."""
865
+ slug = workspace_slug or ctx.require_workspace()
866
+ defaults: dict[str, Any] = {"name": name}
867
+ if label is not None:
868
+ defaults["label"] = label
869
+ if level is not None:
870
+ defaults["level"] = level
871
+ if parent_item_id is not None:
872
+ defaults["parentItemId"] = parent_item_id
873
+ if order is not None:
874
+ defaults["order"] = order
875
+ if order_mode is not None:
876
+ defaults["orderMode"] = order_mode
877
+ if data_type is not None:
878
+ defaults["dataType"] = data_type
879
+ if unit is not None:
880
+ defaults["unit"] = unit
881
+ if scale is not None:
882
+ defaults["scale"] = scale
883
+ if currency is not None:
884
+ defaults["currency"] = currency
885
+ # AddItemOp carries parentItemId in two places by design:
886
+ # - top-level → which parent's children list to insert into (insertion site)
887
+ # - defaults → the new item's @parentItem attribute
888
+ # They're always the same in practice; the backend deduplicates.
889
+ op: dict[str, Any] = {"kind": "add_item", "defaults": defaults}
890
+ if index is not None:
891
+ op["index"] = index
892
+ if parent_item_id is not None:
893
+ op["parentItemId"] = parent_item_id
894
+ _apply(ctx, slug, filename, [op], revision=revision)
895
+
896
+
897
+ @defs.command("update-item")
898
+ @click.argument("filename")
899
+ @click.argument("item_id")
900
+ @click.option(
901
+ "--name",
902
+ default=None,
903
+ help="Rename the itemId; references cascade. Equivalent to `defs rename-item`.",
904
+ )
905
+ @click.option(
906
+ "--label",
907
+ "new_label",
908
+ default=None,
909
+ help="Change the item's DISPLAY LABEL (emits set_item_label). The itemId is "
910
+ "untouched — use `defs rename-item` to change the identifier. Rewrites "
911
+ "the text of the item's first <Label> and keeps its lang; other "
912
+ "locales are left alone and cannot be added here.",
913
+ )
914
+ @click.option("--level", type=int, default=None, help="Change hierarchy level.")
915
+ @click.option("--parent", "parent_item_id", default=None, help="Change parent itemId.")
916
+ @click.option("--clear-parent", is_flag=True, help="Make item a root (explicit-null parentItemId).")
917
+ @click.option(
918
+ "--data-type",
919
+ "data_type",
920
+ default=None,
921
+ callback=_warn_data_type,
922
+ help="Set DataType element (e.g. 'monetary', 'percentage', 'number').",
923
+ )
924
+ @click.option("--unit", default=None, help="Set Unit element (e.g. '%', 'hours', 'tonnes').")
925
+ @click.option("--scale", type=int, default=None, help="Set Scale element.")
926
+ @click.option("--currency", default=None, help="Set Currency element (ISO code).")
927
+ @click.option(
928
+ "--clear-data-type",
929
+ is_flag=True,
930
+ help="Remove the DataType element (explicit-null).",
931
+ )
932
+ @click.option("--clear-unit", is_flag=True, help="Remove the Unit element (explicit-null).")
933
+ @click.option(
934
+ "--clear-scale",
935
+ is_flag=True,
936
+ help="Remove the Scale element (explicit-null).",
937
+ )
938
+ @click.option(
939
+ "--clear-currency",
940
+ is_flag=True,
941
+ help="Remove the Currency element (explicit-null).",
942
+ )
943
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
944
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
945
+ @pass_ctx
946
+ def update_item(
947
+ ctx: Ctx,
948
+ filename: str,
949
+ item_id: str,
950
+ name: str | None,
951
+ new_label: str | None,
952
+ level: int | None,
953
+ parent_item_id: str | None,
954
+ clear_parent: bool,
955
+ data_type: str | None,
956
+ unit: str | None,
957
+ scale: int | None,
958
+ currency: str | None,
959
+ clear_data_type: bool,
960
+ clear_unit: bool,
961
+ clear_scale: bool,
962
+ clear_currency: bool,
963
+ revision: str | None,
964
+ workspace_slug: str | None,
965
+ ) -> None:
966
+ """Patch an item; only fields you pass are sent."""
967
+ slug = workspace_slug or ctx.require_workspace()
968
+ if parent_item_id is not None and clear_parent:
969
+ raise click.UsageError("--parent and --clear-parent are mutually exclusive.")
970
+ if data_type is not None and clear_data_type:
971
+ raise click.UsageError("--data-type and --clear-data-type are mutually exclusive.")
972
+ if unit is not None and clear_unit:
973
+ raise click.UsageError("--unit and --clear-unit are mutually exclusive.")
974
+ if scale is not None and clear_scale:
975
+ raise click.UsageError("--scale and --clear-scale are mutually exclusive.")
976
+ if currency is not None and clear_currency:
977
+ raise click.UsageError("--currency and --clear-currency are mutually exclusive.")
978
+ patch: dict[str, Any] = {}
979
+ if name is not None:
980
+ patch["name"] = name
981
+ if level is not None:
982
+ patch["level"] = level
983
+ if parent_item_id is not None:
984
+ patch["parentItemId"] = parent_item_id
985
+ elif clear_parent:
986
+ patch["parentItemId"] = None
987
+ if data_type is not None:
988
+ patch["dataType"] = data_type
989
+ elif clear_data_type:
990
+ patch["dataType"] = None
991
+ if unit is not None:
992
+ patch["unit"] = unit
993
+ elif clear_unit:
994
+ patch["unit"] = None
995
+ if scale is not None:
996
+ patch["scale"] = scale
997
+ elif clear_scale:
998
+ patch["scale"] = None
999
+ if currency is not None:
1000
+ patch["currency"] = currency
1001
+ elif clear_currency:
1002
+ patch["currency"] = None
1003
+ if not patch and new_label is None:
1004
+ raise click.UsageError(
1005
+ "Provide at least one of --label / --name / --level / --parent / "
1006
+ "--clear-parent / --data-type / --unit / --scale / --currency / "
1007
+ "--clear-data-type / --clear-unit / --clear-scale / --clear-currency."
1008
+ )
1009
+ # UpdateItemPatch has no `label` field, and `set_item_label` had no CLI
1010
+ # command and no guide mention — so an item's display label was
1011
+ # uneditable after creation by any documented path.
1012
+ #
1013
+ # Ordering rule for this batch: every op addresses the item by its
1014
+ # CURRENT id, so ops that only read the id (set_item_label) must run
1015
+ # before the op that can change it (`--name` renames inside the patch).
1016
+ # The reverse order made the label op hit item_not_found after the
1017
+ # rename, and atomicity rolled back the entire batch.
1018
+ ops: list[dict[str, Any]] = []
1019
+ if new_label is not None:
1020
+ ops.append({
1021
+ "kind": "set_item_label", "itemId": item_id, "newLabel": new_label,
1022
+ })
1023
+ if patch:
1024
+ ops.append({"kind": "update_item", "itemId": item_id, "patch": patch})
1025
+ _apply(ctx, slug, filename, ops, revision=revision)
1026
+
1027
+
1028
+ @defs.command("rename-item")
1029
+ @click.argument("filename")
1030
+ @click.argument("item_id")
1031
+ @click.argument("new_name")
1032
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1033
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1034
+ @pass_ctx
1035
+ def rename_item(
1036
+ ctx: Ctx,
1037
+ filename: str,
1038
+ item_id: str,
1039
+ new_name: str,
1040
+ revision: str | None,
1041
+ workspace_slug: str | None,
1042
+ ) -> None:
1043
+ """Rename an item's stable identifier; references cascade."""
1044
+ slug = workspace_slug or ctx.require_workspace()
1045
+ op = {"kind": "rename_item", "itemId": item_id, "newName": new_name}
1046
+ _apply(ctx, slug, filename, [op], revision=revision)
1047
+
1048
+
1049
+ @defs.command("delete-item")
1050
+ @click.argument("filename")
1051
+ @click.argument("item_id")
1052
+ @click.option("--cascade", is_flag=True, help="Also delete all descendants.")
1053
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1054
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1055
+ @pass_ctx
1056
+ def delete_item(
1057
+ ctx: Ctx,
1058
+ filename: str,
1059
+ item_id: str,
1060
+ cascade: bool,
1061
+ revision: str | None,
1062
+ workspace_slug: str | None,
1063
+ ) -> None:
1064
+ """Delete an item, and with --cascade its descendants too.
1065
+
1066
+ Value cells keyed to the deleted item are purged with it. Without
1067
+ --cascade the item's direct children survive: they are re-parented to the
1068
+ deleted item's parent and their levels recomputed.
1069
+
1070
+ Refused while a Calculation, formula, sensitivity block, or Reasoning
1071
+ anchor still references the item — repoint those first. Pass --dry-run to
1072
+ find out which references block the delete without changing anything.
1073
+ """
1074
+ slug = workspace_slug or ctx.require_workspace()
1075
+ kind = "delete_item_with_descendants" if cascade else "delete_item"
1076
+ op = {"kind": kind, "itemId": item_id}
1077
+ _apply(ctx, slug, filename, [op], revision=revision)
1078
+
1079
+
1080
+ @defs.command("reorder-item")
1081
+ @click.argument("filename")
1082
+ @click.argument("item_id")
1083
+ @click.option(
1084
+ "--to-index",
1085
+ "to_index",
1086
+ type=int,
1087
+ required=True,
1088
+ help=(
1089
+ "Final 0-based position among the item's siblings. 0 = first. Lets you "
1090
+ "place a referenced item before its referencer (avoids a forward "
1091
+ "item-order reference)."
1092
+ ),
1093
+ )
1094
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1095
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1096
+ @pass_ctx
1097
+ def reorder_item(
1098
+ ctx: Ctx,
1099
+ filename: str,
1100
+ item_id: str,
1101
+ to_index: int,
1102
+ revision: str | None,
1103
+ workspace_slug: str | None,
1104
+ ) -> None:
1105
+ """Move an item to a new position among its siblings (final-position index)."""
1106
+ slug = workspace_slug or ctx.require_workspace()
1107
+ op = {"kind": "reorder_items", "itemId": item_id, "toIndex": to_index}
1108
+ _apply(ctx, slug, filename, [op], revision=revision)
1109
+
1110
+
1111
+ # ── Period / Scenario / Status ops ─────────────────────────
1112
+
1113
+
1114
+ def _add_context_impl(
1115
+ ctx: Ctx,
1116
+ filename: str,
1117
+ name: str | None,
1118
+ status_ref: str | None,
1119
+ context_ref: str | None,
1120
+ kind: str | None,
1121
+ state: str | None,
1122
+ as_of: str | None,
1123
+ index: int | None,
1124
+ revision: str | None,
1125
+ workspace_slug: str | None,
1126
+ op_kind: str = "add_context",
1127
+ ) -> None:
1128
+ """Shared implementation for `add-context` / `add-period` commands.
1129
+
1130
+ Defaults to the canonical `add_context` op kind (B13). Pass
1131
+ `op_kind="add_period"` to send the legacy on-wire literal — preserved as a
1132
+ one-release deprecation alias.
1133
+ """
1134
+ slug = workspace_slug or ctx.require_workspace()
1135
+ defaults: dict[str, Any] = {}
1136
+ if name is not None:
1137
+ defaults["name"] = name
1138
+ if status_ref is not None:
1139
+ defaults["status"] = status_ref
1140
+ if context_ref is not None:
1141
+ defaults["contextRef"] = context_ref
1142
+ if kind is not None:
1143
+ defaults["kind"] = kind
1144
+ if state is not None:
1145
+ defaults["state"] = state
1146
+ if as_of is not None:
1147
+ defaults["asOf"] = as_of
1148
+ op: dict[str, Any] = {"kind": op_kind, "defaults": defaults}
1149
+ if index is not None:
1150
+ op["index"] = index
1151
+ _apply(ctx, slug, filename, [op], revision=revision)
1152
+
1153
+
1154
+ @defs.command("add-period")
1155
+ @click.argument("filename")
1156
+ @click.option(
1157
+ "--name",
1158
+ help="The period IDENTIFIER (@contextId), e.g. FY2027E — not a display "
1159
+ "label. Must satisfy the reference-id grammar. Set the readable name "
1160
+ "afterwards with `defs update-context --label`.",
1161
+ )
1162
+ @click.option("--status", "status_ref", default=None, help="statusRef (e.g. 'projected').")
1163
+ @click.option("--context", "context_ref", default=None, help="Explicit contextRef (else server-assigned).")
1164
+ @click.option(
1165
+ "--kind",
1166
+ default=None,
1167
+ help=(
1168
+ "Context kind. Omit (or pass 'period' / a temporal alias like 'time' "
1169
+ "/ 'year' / 'annual' / 'fy') for time columns; pass e.g. 'program', "
1170
+ "'segment', 'fund', 'region', 'entity', 'product', 'other' (or any "
1171
+ "snake_case label, ≤32 chars) for non-temporal axes. Always written "
1172
+ "as @kind on the <Context> element."
1173
+ ),
1174
+ )
1175
+ @click.option(
1176
+ "--state",
1177
+ default=None,
1178
+ help=(
1179
+ "Period state: closed | open | future. This is what says whether the "
1180
+ "period is over — the question the A/E suffix on a context id used to "
1181
+ "answer by accident. Only meaningful for --kind period."
1182
+ ),
1183
+ )
1184
+ @click.option(
1185
+ "--as-of",
1186
+ "as_of",
1187
+ default=None,
1188
+ help=(
1189
+ "ISO YYYY-MM-DD the period's numbers are stated as of. Documentation "
1190
+ "and lint input only; never consulted when rendering."
1191
+ ),
1192
+ )
1193
+ @click.option("--index", type=int, default=None, help="Position in contexts list (omit to append at end).")
1194
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1195
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1196
+ @pass_ctx
1197
+ def add_period(
1198
+ ctx: Ctx,
1199
+ filename: str,
1200
+ name: str | None,
1201
+ status_ref: str | None,
1202
+ context_ref: str | None,
1203
+ kind: str | None,
1204
+ state: str | None,
1205
+ as_of: str | None,
1206
+ index: int | None,
1207
+ revision: str | None,
1208
+ workspace_slug: str | None,
1209
+ ) -> None:
1210
+ """Add a ContextDefinition (period, or non-temporal axis member).
1211
+
1212
+ Deprecated alias for `defs add-context`, which does the same thing and is
1213
+ the name to use. This one still works, but may be removed.
1214
+ """
1215
+ _add_context_impl(
1216
+ ctx, filename, name, status_ref, context_ref, kind, state, as_of, index,
1217
+ revision, workspace_slug,
1218
+ op_kind="add_period",
1219
+ )
1220
+
1221
+
1222
+ @defs.command("add-context")
1223
+ @click.argument("filename")
1224
+ @click.option(
1225
+ "--name",
1226
+ help="The context IDENTIFIER (@contextId), e.g. 'Low_Rent' — not a display "
1227
+ "label. Must satisfy the reference-id grammar. Set the readable name "
1228
+ "afterwards with `defs update-context --label`.",
1229
+ )
1230
+ @click.option("--status", "status_ref", default=None, help="statusRef (optional).")
1231
+ @click.option("--context", "context_ref", default=None, help="Explicit contextRef (else server-assigned).")
1232
+ @click.option(
1233
+ "--kind",
1234
+ default=None,
1235
+ help=(
1236
+ "Context kind. Omit (or 'period' / temporal alias) for time columns; "
1237
+ "pass 'program', 'segment', 'fund', 'region', 'entity', 'product', "
1238
+ "'other' (or any snake_case label, ≤32 chars) for non-temporal axes. "
1239
+ "Always written as @kind on the <Context> element."
1240
+ ),
1241
+ )
1242
+ @click.option(
1243
+ "--state",
1244
+ default=None,
1245
+ help=(
1246
+ "Period state: closed | open | future. This is what says whether the "
1247
+ "period is over — the question the A/E suffix on a context id used to "
1248
+ "answer by accident. Only meaningful for --kind period."
1249
+ ),
1250
+ )
1251
+ @click.option(
1252
+ "--as-of",
1253
+ "as_of",
1254
+ default=None,
1255
+ help=(
1256
+ "ISO YYYY-MM-DD the period's numbers are stated as of. Documentation "
1257
+ "and lint input only; never consulted when rendering."
1258
+ ),
1259
+ )
1260
+ @click.option("--index", type=int, default=None, help="Position in contexts list (omit to append at end).")
1261
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1262
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1263
+ @pass_ctx
1264
+ def add_context(
1265
+ ctx: Ctx,
1266
+ filename: str,
1267
+ name: str | None,
1268
+ status_ref: str | None,
1269
+ context_ref: str | None,
1270
+ kind: str | None,
1271
+ state: str | None,
1272
+ as_of: str | None,
1273
+ index: int | None,
1274
+ revision: str | None,
1275
+ workspace_slug: str | None,
1276
+ ) -> None:
1277
+ """Add a ContextDefinition — a period, or a non-temporal axis member.
1278
+
1279
+ Contexts are the columns a value can land in. Use this for both temporal
1280
+ axes (`--kind period`, the default) and non-temporal ones
1281
+ (`--kind program|segment|fund|other`). `defs add-period` is a deprecated
1282
+ alias for this command.
1283
+ """
1284
+ _add_context_impl(
1285
+ ctx, filename, name, status_ref, context_ref, kind, state, as_of, index,
1286
+ revision, workspace_slug,
1287
+ op_kind="add_context",
1288
+ )
1289
+
1290
+
1291
+ @defs.command("delete-context")
1292
+ @click.argument("filename")
1293
+ @click.argument("context_ref")
1294
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1295
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1296
+ @pass_ctx
1297
+ def delete_context(
1298
+ ctx: Ctx,
1299
+ filename: str,
1300
+ context_ref: str,
1301
+ revision: str | None,
1302
+ workspace_slug: str | None,
1303
+ ) -> None:
1304
+ """Delete a Context (period or non-temporal axis member).
1305
+
1306
+ Refused while a Calculation, Presentation Block, or Reasoning anchor still
1307
+ references the context — reassign those first. Values in the deleted
1308
+ context go with it. Pass --dry-run to see what would happen first.
1309
+ """
1310
+ slug = workspace_slug or ctx.require_workspace()
1311
+ op = {"kind": "delete_context", "contextRef": context_ref}
1312
+ _apply(ctx, slug, filename, [op], revision=revision)
1313
+
1314
+
1315
+ @defs.command("rename-context")
1316
+ @click.argument("filename")
1317
+ @click.argument("context_ref")
1318
+ @click.argument("new_name")
1319
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1320
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1321
+ @pass_ctx
1322
+ def rename_context(
1323
+ ctx: Ctx,
1324
+ filename: str,
1325
+ context_ref: str,
1326
+ new_name: str,
1327
+ revision: str | None,
1328
+ workspace_slug: str | None,
1329
+ ) -> None:
1330
+ """Rename a Context's @contextId; references cascade.
1331
+
1332
+ Values, Calculations, Presentation Blocks, and Reasoning anchors are all
1333
+ repointed, so nothing is left dangling.
1334
+ """
1335
+ slug = workspace_slug or ctx.require_workspace()
1336
+ op = {"kind": "rename_context", "contextRef": context_ref, "newName": new_name}
1337
+ _apply(ctx, slug, filename, [op], revision=revision)
1338
+
1339
+
1340
+ @defs.command("rename-dimension")
1341
+ @click.argument("filename")
1342
+ @click.argument("dimension_id")
1343
+ @click.argument("new_name")
1344
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1345
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1346
+ @pass_ctx
1347
+ def rename_dimension(
1348
+ ctx: Ctx,
1349
+ filename: str,
1350
+ dimension_id: str,
1351
+ new_name: str,
1352
+ revision: str | None,
1353
+ workspace_slug: str | None,
1354
+ ) -> None:
1355
+ """Rename a custom Dimension's @dimensionId. Cascades through every
1356
+ @customDimensions carrier (Values, ItemGroups, Calculations) and formula
1357
+ interiors.
1358
+ """
1359
+ slug = workspace_slug or ctx.require_workspace()
1360
+ op = {"kind": "rename_dimension", "dimensionId": dimension_id, "newName": new_name}
1361
+ _apply(ctx, slug, filename, [op], revision=revision)
1362
+
1363
+
1364
+ @defs.command("rename-member")
1365
+ @click.argument("filename")
1366
+ @click.argument("dimension_id")
1367
+ @click.argument("member_id")
1368
+ @click.argument("new_name")
1369
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1370
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1371
+ @pass_ctx
1372
+ def rename_member(
1373
+ ctx: Ctx,
1374
+ filename: str,
1375
+ dimension_id: str,
1376
+ member_id: str,
1377
+ new_name: str,
1378
+ revision: str | None,
1379
+ workspace_slug: str | None,
1380
+ ) -> None:
1381
+ """Rename one Member's @memberId within a custom Dimension. The dimension
1382
+ id disambiguates (member ids are not globally unique). Cascades through
1383
+ every @customDimensions carrier and formula interiors.
1384
+ """
1385
+ slug = workspace_slug or ctx.require_workspace()
1386
+ op = {
1387
+ "kind": "rename_member",
1388
+ "dimensionId": dimension_id,
1389
+ "memberId": member_id,
1390
+ "newName": new_name,
1391
+ }
1392
+ _apply(ctx, slug, filename, [op], revision=revision)
1393
+
1394
+
1395
+ @defs.command("reorder-contexts")
1396
+ @click.argument("filename")
1397
+ @click.argument("context_ref")
1398
+ @click.argument("to_index", type=int)
1399
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1400
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1401
+ @pass_ctx
1402
+ def reorder_contexts(
1403
+ ctx: Ctx,
1404
+ filename: str,
1405
+ context_ref: str,
1406
+ to_index: int,
1407
+ revision: str | None,
1408
+ workspace_slug: str | None,
1409
+ ) -> None:
1410
+ """Move a Context to a new position in the contexts list."""
1411
+ slug = workspace_slug or ctx.require_workspace()
1412
+ op = {"kind": "reorder_contexts", "contextRef": context_ref, "toIndex": to_index}
1413
+ _apply(ctx, slug, filename, [op], revision=revision)
1414
+
1415
+
1416
+ @defs.command("update-context")
1417
+ @click.argument("filename")
1418
+ @click.argument("context_ref")
1419
+ @click.option(
1420
+ "--name",
1421
+ default=None,
1422
+ help="Rename the contextId; references cascade. NOT the display name — "
1423
+ "see --label.",
1424
+ )
1425
+ @click.option(
1426
+ "--label",
1427
+ "new_label",
1428
+ default=None,
1429
+ help="Change the context's DISPLAY LABEL (emits set_context_label). The "
1430
+ "contextId is untouched — use --name to change the identifier.",
1431
+ )
1432
+ @click.option("--status", "status_ref", default=None, help="Set statusRef.")
1433
+ @click.option("--clear-status", is_flag=True, help="Clear statusRef (explicit-null).")
1434
+ @click.option(
1435
+ "--kind",
1436
+ default=None,
1437
+ help=(
1438
+ "Context kind. Omit / null / 'period' / temporal alias collapses to "
1439
+ "canonical 'period'; any other snake_case label marks a non-temporal "
1440
+ "context. Always written to @kind."
1441
+ ),
1442
+ )
1443
+ @click.option(
1444
+ "--state",
1445
+ default=None,
1446
+ help=(
1447
+ "Period state: closed | open | future. This is what says whether the "
1448
+ "period is over — the question the A/E suffix on a context id used to "
1449
+ "answer by accident. Only meaningful for --kind period."
1450
+ ),
1451
+ )
1452
+ @click.option(
1453
+ "--as-of",
1454
+ "as_of",
1455
+ default=None,
1456
+ help=(
1457
+ "ISO YYYY-MM-DD the period's numbers are stated as of. Documentation "
1458
+ "and lint input only; never consulted when rendering."
1459
+ ),
1460
+ )
1461
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1462
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1463
+ @pass_ctx
1464
+ def update_context(
1465
+ ctx: Ctx,
1466
+ filename: str,
1467
+ context_ref: str,
1468
+ name: str | None,
1469
+ new_label: str | None,
1470
+ status_ref: str | None,
1471
+ clear_status: bool,
1472
+ kind: str | None,
1473
+ state: str | None,
1474
+ as_of: str | None,
1475
+ revision: str | None,
1476
+ workspace_slug: str | None,
1477
+ ) -> None:
1478
+ """Patch a Context's name / status / kind; only fields you pass are sent."""
1479
+ slug = workspace_slug or ctx.require_workspace()
1480
+ if status_ref is not None and clear_status:
1481
+ raise click.UsageError("--status and --clear-status are mutually exclusive.")
1482
+ patch: dict[str, Any] = {}
1483
+ if name is not None:
1484
+ patch["name"] = name
1485
+ if status_ref is not None:
1486
+ patch["status"] = status_ref
1487
+ elif clear_status:
1488
+ patch["status"] = None
1489
+ if kind is not None:
1490
+ patch["kind"] = kind
1491
+ # An empty string is an explicit clear, so these test `is not None` rather
1492
+ # than truthiness — `--state ''` must reach the server as a null.
1493
+ if state is not None:
1494
+ patch["state"] = state or None
1495
+ if as_of is not None:
1496
+ patch["asOf"] = as_of or None
1497
+ if not patch and new_label is None:
1498
+ raise click.UsageError(
1499
+ "Provide at least one of --label / --name / --status / "
1500
+ "--clear-status / --kind / --state / --as-of."
1501
+ )
1502
+ # Label op first: both address the context by its CURRENT id, and --name
1503
+ # inside the patch can change it. See `defs update-item` for the same rule.
1504
+ ops: list[dict[str, Any]] = []
1505
+ if new_label is not None:
1506
+ ops.append({
1507
+ "kind": "set_context_label", "contextRef": context_ref,
1508
+ "newLabel": new_label,
1509
+ })
1510
+ if patch:
1511
+ ops.append({"kind": "update_context", "contextRef": context_ref, "patch": patch})
1512
+ _apply(ctx, slug, filename, ops, revision=revision)
1513
+
1514
+
1515
+ @defs.command("add-scenario")
1516
+ @click.argument("filename")
1517
+ @click.option(
1518
+ "--name",
1519
+ help="The scenario IDENTIFIER (@scenarioId), e.g. 'Bull' — not a display "
1520
+ "label. Must satisfy the reference-id grammar. Set the readable name "
1521
+ "afterwards with `defs update-scenario --label`.",
1522
+ )
1523
+ @click.option(
1524
+ "--base",
1525
+ "base_scenario_ref",
1526
+ default=None,
1527
+ hidden=True,
1528
+ help="(removed) @baseScenarioRef no longer exists — see `deepcell guide revise/scenarios`.",
1529
+ )
1530
+ @click.option(
1531
+ "--context",
1532
+ "context_ref",
1533
+ default=None,
1534
+ help="The scenario IDENTIFIER (@scenarioId) under the op's wire name, "
1535
+ "contextRef — the same thing as --name, not a period. Legacy "
1536
+ "spelling; if both are given, --context wins.",
1537
+ )
1538
+ @click.option(
1539
+ "--is-default",
1540
+ "is_default",
1541
+ is_flag=True,
1542
+ default=False,
1543
+ help="Mark as the document's default scenario — clears @isDefault from every other scenario.",
1544
+ )
1545
+ @click.option("--index", type=int, default=None, help="Position (omit to append at end).")
1546
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1547
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1548
+ @pass_ctx
1549
+ def add_scenario(
1550
+ ctx: Ctx,
1551
+ filename: str,
1552
+ name: str | None,
1553
+ base_scenario_ref: str | None,
1554
+ context_ref: str | None,
1555
+ is_default: bool,
1556
+ index: int | None,
1557
+ revision: str | None,
1558
+ workspace_slug: str | None,
1559
+ ) -> None:
1560
+ """Add a ScenarioDefinition."""
1561
+ if base_scenario_ref is not None:
1562
+ raise click.UsageError(
1563
+ "--base was removed: @baseScenarioRef was dead data the calc engine "
1564
+ "never read, dropped alongside issue #505 (the backend silently "
1565
+ "ignored this flag). Every scenario implicitly shares the base "
1566
+ "model's values; make it diverge with VariableOverride elements — "
1567
+ "see `deepcell guide revise/scenarios`."
1568
+ )
1569
+ slug = workspace_slug or ctx.require_workspace()
1570
+ defaults: dict[str, Any] = {}
1571
+ if name is not None:
1572
+ defaults["name"] = name
1573
+ if context_ref is not None:
1574
+ defaults["contextRef"] = context_ref
1575
+ if is_default:
1576
+ defaults["is_default"] = True
1577
+ op: dict[str, Any] = {"kind": "add_scenario", "defaults": defaults}
1578
+ if index is not None:
1579
+ op["index"] = index
1580
+ _apply(ctx, slug, filename, [op], revision=revision)
1581
+
1582
+
1583
+ @defs.command("add-status")
1584
+ @click.argument("filename")
1585
+ @click.option(
1586
+ "--name",
1587
+ help="The status IDENTIFIER (@statusId), e.g. 'Forecast' — not a display "
1588
+ "label. Must satisfy the reference-id grammar. Set the readable name "
1589
+ "afterwards with `defs update-status --label`.",
1590
+ )
1591
+ @click.option(
1592
+ "--ref",
1593
+ "status_ref",
1594
+ default=None,
1595
+ help="The status IDENTIFIER (@statusId) under the op's wire name, "
1596
+ "statusRef — the same thing as --name. If both are given, --ref "
1597
+ "wins.",
1598
+ )
1599
+ @click.option(
1600
+ "--color",
1601
+ default=None,
1602
+ hidden=True,
1603
+ help="(removed) color is a FormatDefinitions concern — see `deepcell defs add-format`.",
1604
+ )
1605
+ @click.option(
1606
+ "--is-default",
1607
+ "is_default",
1608
+ is_flag=True,
1609
+ default=False,
1610
+ help="Mark as the document's default status — clears @isDefault from every other status.",
1611
+ )
1612
+ @click.option(
1613
+ "--archetype",
1614
+ default=None,
1615
+ help=(
1616
+ "What this status MEANS: actual | preliminary | restated | estimate | "
1617
+ "guidance | consensus | forecast | budget | plan | target. Its "
1618
+ "realized/expected nature is derived from it. Omitting it leaves the "
1619
+ "meaning to be guessed from the id's spelling — see `deepcell ref status`."
1620
+ ),
1621
+ )
1622
+ @click.option(
1623
+ "--assurance",
1624
+ default=None,
1625
+ help="Optional refinement: audited | reviewed | unaudited.",
1626
+ )
1627
+ @click.option(
1628
+ "--authority",
1629
+ default=None,
1630
+ help="Optional refinement — who asserted the number: reported | derived | "
1631
+ "guidance | consensus | third_party.",
1632
+ )
1633
+ @click.option("--index", type=int, default=None, help="Position (omit to append at end).")
1634
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1635
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1636
+ @pass_ctx
1637
+ def add_status(
1638
+ ctx: Ctx,
1639
+ filename: str,
1640
+ name: str | None,
1641
+ status_ref: str | None,
1642
+ color: str | None,
1643
+ is_default: bool,
1644
+ archetype: str | None,
1645
+ assurance: str | None,
1646
+ authority: str | None,
1647
+ index: int | None,
1648
+ revision: str | None,
1649
+ workspace_slug: str | None,
1650
+ ) -> None:
1651
+ """Add a StatusDefinition."""
1652
+ if color is not None:
1653
+ raise click.UsageError(
1654
+ "--color was removed: @color on <Status> was dropped alongside "
1655
+ "issue #506 (the backend silently ignored this flag). Visual color "
1656
+ "is a FormatDefinitions concern — add a rule with font_color / "
1657
+ "background_color via `deepcell defs add-format` or `deepcell defs "
1658
+ "set-format`; see `deepcell ref format`."
1659
+ )
1660
+ slug = workspace_slug or ctx.require_workspace()
1661
+ defaults: dict[str, Any] = {}
1662
+ if name is not None:
1663
+ defaults["name"] = name
1664
+ if status_ref is not None:
1665
+ defaults["statusRef"] = status_ref
1666
+ if is_default:
1667
+ defaults["is_default"] = True
1668
+ for field, value in (
1669
+ ("archetype", archetype),
1670
+ ("assurance", assurance),
1671
+ ("authority", authority),
1672
+ ):
1673
+ if value is not None:
1674
+ defaults[field] = value
1675
+ op: dict[str, Any] = {"kind": "add_status", "defaults": defaults}
1676
+ if index is not None:
1677
+ op["index"] = index
1678
+ _apply(ctx, slug, filename, [op], revision=revision)
1679
+
1680
+
1681
+ # ── Header / Metadata ops ──────────────────────────────────
1682
+
1683
+
1684
+ @defs.group("header")
1685
+ def header() -> None:
1686
+ """Edit <Metadata><Property> entries (title, author, precision, ...).
1687
+
1688
+ \b
1689
+ Examples:
1690
+ deepcell defs header set model.deepcell title "Acme DCF Q3"
1691
+ deepcell defs header set model.deepcell decimal_places 12 --type number
1692
+ deepcell defs header unset model.deepcell author
1693
+ """
1694
+
1695
+
1696
+ @header.command("set", cls=_DefsNegativeNumberCommand)
1697
+ @click.argument("filename")
1698
+ @click.argument("key")
1699
+ @click.argument("value")
1700
+ @click.option(
1701
+ "--type",
1702
+ "prop_type",
1703
+ type=click.Choice(["string", "number", "boolean", "datetime"]),
1704
+ default="string",
1705
+ help="Property type tag written into <Property @type>.",
1706
+ )
1707
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1708
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1709
+ @pass_ctx
1710
+ def header_set(
1711
+ ctx: Ctx,
1712
+ filename: str,
1713
+ key: str,
1714
+ value: str,
1715
+ prop_type: str,
1716
+ revision: str | None,
1717
+ workspace_slug: str | None,
1718
+ ) -> None:
1719
+ """Set or replace a Header property."""
1720
+ slug = workspace_slug or ctx.require_workspace()
1721
+ op = {
1722
+ "kind": "set_header_property",
1723
+ "key": key,
1724
+ "value": value,
1725
+ "type": prop_type,
1726
+ }
1727
+ _apply(ctx, slug, filename, [op], revision=revision)
1728
+
1729
+
1730
+ @header.command("unset")
1731
+ @click.argument("filename")
1732
+ @click.argument("key")
1733
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1734
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1735
+ @pass_ctx
1736
+ def header_unset(
1737
+ ctx: Ctx,
1738
+ filename: str,
1739
+ key: str,
1740
+ revision: str | None,
1741
+ workspace_slug: str | None,
1742
+ ) -> None:
1743
+ """Delete a Header property."""
1744
+ slug = workspace_slug or ctx.require_workspace()
1745
+ op = {"kind": "delete_header_property", "key": key}
1746
+ _apply(ctx, slug, filename, [op], revision=revision)
1747
+
1748
+
1749
+ # ── Format ops (B15) ───────────────────────────────────────
1750
+
1751
+
1752
+ def _parse_rule_kvs(rule_strs: tuple[str, ...]) -> list[dict[str, Any]]:
1753
+ """Parse repeated --rule "target=...,bg_color=...,font_color=..." into
1754
+ a list of rule dicts. Returns raw dicts ready for the wire (snake_case
1755
+ keys match the backend Pydantic model).
1756
+
1757
+ The kv separator is ``;``. Within a value, commas are allowed (so an
1758
+ Excel-style ``number_format=#,##0`` round-trips intact). For
1759
+ back-compat with the previous comma-separated form, a string that
1760
+ contains no ``;`` is parsed by splitting on ``,`` *only if* doing so
1761
+ yields valid ``key=value`` pairs that don't accidentally split a
1762
+ numberFormat — namely, no pair starts with a non-letter (Excel
1763
+ patterns like ``#,##0`` start with ``#``). If the comma-split would
1764
+ produce an invalid pair, we treat the whole string as a single value.
1765
+ """
1766
+ rules: list[dict[str, Any]] = []
1767
+ for raw in rule_strs:
1768
+ rule: dict[str, Any] = {}
1769
+ pairs: list[str]
1770
+ if ";" in raw:
1771
+ pairs = [p.strip() for p in raw.split(";") if p.strip()]
1772
+ else:
1773
+ # Comma-split, but coalesce fragments that don't look like
1774
+ # 'key=value' (they belong to a value containing commas, e.g.
1775
+ # the trailing '##0' of '#,##0').
1776
+ raw_parts = [p.strip() for p in raw.split(",") if p.strip()]
1777
+ coalesced: list[str] = []
1778
+ for part in raw_parts:
1779
+ if "=" in part and re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*=", part):
1780
+ coalesced.append(part)
1781
+ elif coalesced:
1782
+ coalesced[-1] = coalesced[-1] + "," + part
1783
+ else:
1784
+ coalesced.append(part)
1785
+ pairs = coalesced
1786
+ for p in pairs:
1787
+ if "=" not in p:
1788
+ raise click.UsageError(
1789
+ f"--rule entry {p!r} must be 'key=value'"
1790
+ )
1791
+ k, v = p.split("=", 1)
1792
+ rule[k.strip()] = v.strip()
1793
+ if "target" not in rule:
1794
+ raise click.UsageError(
1795
+ f"--rule entry {raw!r} is missing the required 'target' key"
1796
+ )
1797
+ rules.append(rule)
1798
+ return rules
1799
+
1800
+
1801
+ # ── Scenario / status lifecycle ────────────────────────────
1802
+ #
1803
+ # `add-scenario` and `add-status` shipped alone: every other definition
1804
+ # family (items, contexts, calcs, formats) has delete / rename / update, so
1805
+ # a scenario or status could be created from the CLI and then never fixed.
1806
+
1807
+
1808
+ @defs.command("delete-scenario")
1809
+ @click.argument("filename")
1810
+ @click.argument("scenario_ref")
1811
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1812
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1813
+ @pass_ctx
1814
+ def delete_scenario(
1815
+ ctx: Ctx,
1816
+ filename: str,
1817
+ scenario_ref: str,
1818
+ revision: str | None,
1819
+ workspace_slug: str | None,
1820
+ ) -> None:
1821
+ """Delete a ScenarioDefinition by its contextRef.
1822
+
1823
+ Values carrying this scenario go with it. Pass --dry-run first to see
1824
+ what the delete would take with it.
1825
+ """
1826
+ slug = workspace_slug or ctx.require_workspace()
1827
+ op = {"kind": "delete_scenario", "scenarioContextRef": scenario_ref}
1828
+ _apply(ctx, slug, filename, [op], revision=revision)
1829
+
1830
+
1831
+ @defs.command("rename-scenario")
1832
+ @click.argument("filename")
1833
+ @click.argument("scenario_ref")
1834
+ @click.argument("new_name")
1835
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1836
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1837
+ @pass_ctx
1838
+ def rename_scenario(
1839
+ ctx: Ctx,
1840
+ filename: str,
1841
+ scenario_ref: str,
1842
+ new_name: str,
1843
+ revision: str | None,
1844
+ workspace_slug: str | None,
1845
+ ) -> None:
1846
+ """Rename a scenario's IDENTIFIER (@scenarioId); references cascade.
1847
+
1848
+ NOT the display name — this rewrites `@scenarioRef` on Values and every
1849
+ sensitivity block's `@scenarioRefs` CSV, and NEW_NAME must satisfy the
1850
+ reference-id grammar (letters, digits, `_`, `.`). For the human-readable
1851
+ name use `deepcell defs update-scenario --label`.
1852
+ """
1853
+ slug = workspace_slug or ctx.require_workspace()
1854
+ op = {
1855
+ "kind": "rename_scenario",
1856
+ "scenarioContextRef": scenario_ref,
1857
+ "newName": new_name,
1858
+ }
1859
+ _apply(ctx, slug, filename, [op], revision=revision)
1860
+
1861
+
1862
+ @defs.command("update-scenario")
1863
+ @click.argument("filename")
1864
+ @click.argument("scenario_ref")
1865
+ @click.option(
1866
+ "--name",
1867
+ default=None,
1868
+ help="Rename the scenarioId; references cascade. Equivalent to "
1869
+ "`defs rename-scenario`. NOT the display name — see --label.",
1870
+ )
1871
+ @click.option(
1872
+ "--label",
1873
+ "new_label",
1874
+ default=None,
1875
+ help="Change the scenario's DISPLAY LABEL (emits set_scenario_label). The "
1876
+ "scenarioId is untouched — use --name to change the identifier.",
1877
+ )
1878
+ @click.option(
1879
+ "--is-default/--no-is-default",
1880
+ "is_default",
1881
+ default=None,
1882
+ help=(
1883
+ "Make this the document's default scenario (clears @isDefault from "
1884
+ "every other scenario), or strip the flag from this one."
1885
+ ),
1886
+ )
1887
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1888
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1889
+ @pass_ctx
1890
+ def update_scenario(
1891
+ ctx: Ctx,
1892
+ filename: str,
1893
+ scenario_ref: str,
1894
+ name: str | None,
1895
+ new_label: str | None,
1896
+ is_default: bool | None,
1897
+ revision: str | None,
1898
+ workspace_slug: str | None,
1899
+ ) -> None:
1900
+ """Patch a scenario; only the fields you pass are sent."""
1901
+ slug = workspace_slug or ctx.require_workspace()
1902
+ patch: dict[str, Any] = {}
1903
+ if name is not None:
1904
+ patch["name"] = name
1905
+ if is_default is not None:
1906
+ patch["is_default"] = is_default
1907
+ if not patch and new_label is None:
1908
+ raise click.UsageError(
1909
+ "Provide at least one of --label / --name / --is-default / "
1910
+ "--no-is-default."
1911
+ )
1912
+ # Label op first — same ordering rule as `defs update-item`.
1913
+ ops: list[dict[str, Any]] = []
1914
+ if new_label is not None:
1915
+ ops.append({
1916
+ "kind": "set_scenario_label", "scenarioContextRef": scenario_ref,
1917
+ "newLabel": new_label,
1918
+ })
1919
+ if patch:
1920
+ ops.append({
1921
+ "kind": "update_scenario",
1922
+ "scenarioContextRef": scenario_ref,
1923
+ "patch": patch,
1924
+ })
1925
+ _apply(ctx, slug, filename, ops, revision=revision)
1926
+
1927
+
1928
+ @defs.command("reorder-scenarios")
1929
+ @click.argument("filename")
1930
+ @click.argument("scenario_ref")
1931
+ @click.option(
1932
+ "--to-index",
1933
+ "to_index",
1934
+ type=click.IntRange(min=0),
1935
+ required=True,
1936
+ help="Final 0-based position among the scenarios. 0 = first.",
1937
+ )
1938
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1939
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1940
+ @pass_ctx
1941
+ def reorder_scenarios(
1942
+ ctx: Ctx,
1943
+ filename: str,
1944
+ scenario_ref: str,
1945
+ to_index: int,
1946
+ revision: str | None,
1947
+ workspace_slug: str | None,
1948
+ ) -> None:
1949
+ """Move a scenario to a new position in the scenario list."""
1950
+ slug = workspace_slug or ctx.require_workspace()
1951
+ op = {
1952
+ "kind": "reorder_scenarios",
1953
+ "scenarioContextRef": scenario_ref,
1954
+ "toIndex": to_index,
1955
+ }
1956
+ _apply(ctx, slug, filename, [op], revision=revision)
1957
+
1958
+
1959
+ @defs.command("delete-status")
1960
+ @click.argument("filename")
1961
+ @click.argument("status_ref")
1962
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1963
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1964
+ @pass_ctx
1965
+ def delete_status(
1966
+ ctx: Ctx,
1967
+ filename: str,
1968
+ status_ref: str,
1969
+ revision: str | None,
1970
+ workspace_slug: str | None,
1971
+ ) -> None:
1972
+ """Delete a StatusDefinition by its statusRef.
1973
+
1974
+ Statuses partition Values (actual vs budget vs forecast), so deleting
1975
+ one affects every cell filed under it — run --dry-run first.
1976
+ """
1977
+ slug = workspace_slug or ctx.require_workspace()
1978
+ op = {"kind": "delete_status", "statusRef": status_ref}
1979
+ _apply(ctx, slug, filename, [op], revision=revision)
1980
+
1981
+
1982
+ @defs.command("rename-status")
1983
+ @click.argument("filename")
1984
+ @click.argument("status_ref")
1985
+ @click.argument("new_name")
1986
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
1987
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
1988
+ @pass_ctx
1989
+ def rename_status(
1990
+ ctx: Ctx,
1991
+ filename: str,
1992
+ status_ref: str,
1993
+ new_name: str,
1994
+ revision: str | None,
1995
+ workspace_slug: str | None,
1996
+ ) -> None:
1997
+ """Change a status's IDENTIFIER (@statusId); references cascade.
1998
+
1999
+ NEW_NAME must be a valid reference id (letters, digits, '_' or '.'), and
2000
+ every @statusRef in the document is rewritten to match — contexts, values,
2001
+ item groups, calcs, block statusRefs, reasoning anchors, and #status tokens
2002
+ inside formulas. To change the human-readable name shown in the UI, use
2003
+ `defs update-status --label` instead; it touches one element's text.
2004
+ """
2005
+ slug = workspace_slug or ctx.require_workspace()
2006
+ op = {
2007
+ "kind": "rename_status",
2008
+ "statusRef": status_ref,
2009
+ "newName": new_name,
2010
+ }
2011
+ _apply(ctx, slug, filename, [op], revision=revision)
2012
+
2013
+
2014
+ @defs.command("update-status")
2015
+ @click.argument("filename")
2016
+ @click.argument("status_ref")
2017
+ @click.option(
2018
+ "--name",
2019
+ default=None,
2020
+ help="Rename the statusId; references cascade. Equivalent to "
2021
+ "`defs rename-status`. NOT the display name — see --label.",
2022
+ )
2023
+ @click.option(
2024
+ "--label",
2025
+ "new_label",
2026
+ default=None,
2027
+ help="Change the status's DISPLAY LABEL (emits set_status_label). The "
2028
+ "statusId is untouched — use --name to change the identifier.",
2029
+ )
2030
+ @click.option(
2031
+ "--is-default/--no-is-default",
2032
+ "is_default",
2033
+ default=None,
2034
+ help=(
2035
+ "Make this the document's default status (clears @isDefault from "
2036
+ "every other status), or strip the flag from this one."
2037
+ ),
2038
+ )
2039
+ @click.option(
2040
+ "--archetype",
2041
+ default=None,
2042
+ help=(
2043
+ "What this status MEANS: actual | preliminary | restated | estimate | "
2044
+ "guidance | consensus | forecast | budget | plan | target. "
2045
+ "Pass an empty string to clear it (and fall back to inference)."
2046
+ ),
2047
+ )
2048
+ @click.option("--assurance", default=None, help="audited | reviewed | unaudited.")
2049
+ @click.option(
2050
+ "--authority",
2051
+ default=None,
2052
+ help="reported | derived | guidance | consensus | third_party.",
2053
+ )
2054
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
2055
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2056
+ @pass_ctx
2057
+ def update_status(
2058
+ ctx: Ctx,
2059
+ filename: str,
2060
+ status_ref: str,
2061
+ name: str | None,
2062
+ new_label: str | None,
2063
+ is_default: bool | None,
2064
+ archetype: str | None,
2065
+ assurance: str | None,
2066
+ authority: str | None,
2067
+ revision: str | None,
2068
+ workspace_slug: str | None,
2069
+ ) -> None:
2070
+ """Patch a status; only the fields you pass are sent."""
2071
+ slug = workspace_slug or ctx.require_workspace()
2072
+ patch: dict[str, Any] = {}
2073
+ if name is not None:
2074
+ patch["name"] = name
2075
+ if is_default is not None:
2076
+ patch["is_default"] = is_default
2077
+ # An empty string is an explicit clear, which is why these test `is not
2078
+ # None` rather than truthiness — `--archetype ''` must reach the server as
2079
+ # a null so the attribute is stripped.
2080
+ for field, value in (
2081
+ ("archetype", archetype),
2082
+ ("assurance", assurance),
2083
+ ("authority", authority),
2084
+ ):
2085
+ if value is not None:
2086
+ patch[field] = value or None
2087
+ if not patch and new_label is None:
2088
+ raise click.UsageError(
2089
+ "Provide at least one of --label / --name / --is-default / "
2090
+ "--no-is-default / --archetype / --assurance / --authority."
2091
+ )
2092
+ # `UpdateStatusPatch` has no `label` field and `patch.name` renames the ID,
2093
+ # so a status's display label was uneditable by any path until
2094
+ # `set_status_label` — see that op's docstring.
2095
+ #
2096
+ # Same ordering rule as `defs update-item`: both ops address the status by
2097
+ # its CURRENT id, so the label op (which only reads the id) must run before
2098
+ # the op that can change it. Reversed, the label op hits status_not_found
2099
+ # after the rename and atomicity rolls back the whole batch.
2100
+ ops: list[dict[str, Any]] = []
2101
+ if new_label is not None:
2102
+ ops.append({
2103
+ "kind": "set_status_label", "statusRef": status_ref,
2104
+ "newLabel": new_label,
2105
+ })
2106
+ if patch:
2107
+ ops.append({"kind": "update_status", "statusRef": status_ref, "patch": patch})
2108
+ _apply(ctx, slug, filename, ops, revision=revision)
2109
+
2110
+
2111
+ @defs.command("add-format")
2112
+ @click.argument("filename")
2113
+ @click.argument("format_id")
2114
+ @click.option(
2115
+ "--rule",
2116
+ "rule_strs",
2117
+ multiple=True,
2118
+ help=(
2119
+ "One rule as semicolon-separated key=value pairs, e.g. "
2120
+ "'target=default; font_color=#000000; number_format=#,##0'. "
2121
+ "Commas inside a value (such as Excel number formats) are preserved. "
2122
+ "Repeat the flag for multiple rules. Keys: target, font_color, "
2123
+ "background_color, font_weight, font_style, font_name, font_size, "
2124
+ "text_align, indent, number_format, border_top/bottom/left/right."
2125
+ ),
2126
+ )
2127
+ @click.option(
2128
+ "--inherit/--no-inherit",
2129
+ "inherit",
2130
+ default=None,
2131
+ help=(
2132
+ "--no-inherit opts the Format out of the IB default base (a total "
2133
+ 'custom theme; writes @inherit="false"). Default inherits the base '
2134
+ "(add-on model). See `deepcell ref format`."
2135
+ ),
2136
+ )
2137
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
2138
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2139
+ @pass_ctx
2140
+ def add_format(
2141
+ ctx: Ctx,
2142
+ filename: str,
2143
+ format_id: str,
2144
+ rule_strs: tuple[str, ...],
2145
+ inherit: bool | None,
2146
+ revision: str | None,
2147
+ workspace_slug: str | None,
2148
+ ) -> None:
2149
+ """Add a <Format formatId="..."> to FormatDefinitions."""
2150
+ slug = workspace_slug or ctx.require_workspace()
2151
+ rules = _parse_rule_kvs(rule_strs)
2152
+ op: dict[str, Any] = {"kind": "add_format", "formatId": format_id, "rules": rules}
2153
+ if inherit is not None:
2154
+ op["inherit"] = inherit
2155
+ _apply(ctx, slug, filename, [op], revision=revision)
2156
+
2157
+
2158
+ @defs.command("delete-format")
2159
+ @click.argument("filename")
2160
+ @click.argument("format_id")
2161
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
2162
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2163
+ @pass_ctx
2164
+ def delete_format(
2165
+ ctx: Ctx,
2166
+ filename: str,
2167
+ format_id: str,
2168
+ revision: str | None,
2169
+ workspace_slug: str | None,
2170
+ ) -> None:
2171
+ """Delete a <Format> by formatId.
2172
+
2173
+ Refused while any <Block @formatRef> points at it; the error names the
2174
+ referencing blocks. No flag repoints a block's @formatRef, so in practice
2175
+ a referenced format is deleted by first renaming the format the blocks
2176
+ should use onto this id (`deepcell defs update-format --new-id`, which
2177
+ cascades through every @formatRef). Pass --dry-run to learn which blocks
2178
+ reference it without changing anything.
2179
+ """
2180
+ slug = workspace_slug or ctx.require_workspace()
2181
+ op = {"kind": "delete_format", "formatId": format_id}
2182
+ _apply(ctx, slug, filename, [op], revision=revision)
2183
+
2184
+
2185
+ @defs.command("update-format")
2186
+ @click.argument("filename")
2187
+ @click.argument("format_id")
2188
+ @click.option(
2189
+ "--new-id",
2190
+ "new_id",
2191
+ default=None,
2192
+ help="New formatId; cascades through every <Block @formatRef>.",
2193
+ )
2194
+ @click.option(
2195
+ "--inherit/--no-inherit",
2196
+ "inherit",
2197
+ default=None,
2198
+ help=(
2199
+ "Toggle the IB default base. --no-inherit makes this a total custom "
2200
+ 'theme (@inherit="false"); --inherit returns to the add-on default. '
2201
+ "See `deepcell ref format`."
2202
+ ),
2203
+ )
2204
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
2205
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2206
+ @pass_ctx
2207
+ def update_format(
2208
+ ctx: Ctx,
2209
+ filename: str,
2210
+ format_id: str,
2211
+ new_id: str | None,
2212
+ inherit: bool | None,
2213
+ revision: str | None,
2214
+ workspace_slug: str | None,
2215
+ ) -> None:
2216
+ """Rename a <Format> and/or toggle its IB-default inheritance.
2217
+
2218
+ <Block @formatRef> references cascade on rename. Provide at least one of
2219
+ --new-id / --inherit/--no-inherit.
2220
+ """
2221
+ if new_id is None and inherit is None:
2222
+ raise click.UsageError(
2223
+ "provide at least one of --new-id or --inherit/--no-inherit."
2224
+ )
2225
+ slug = workspace_slug or ctx.require_workspace()
2226
+ patch: dict[str, Any] = {"newId": new_id}
2227
+ if inherit is not None:
2228
+ patch["inherit"] = inherit
2229
+ op = {
2230
+ "kind": "update_format",
2231
+ "formatId": format_id,
2232
+ "patch": patch,
2233
+ }
2234
+ _apply(ctx, slug, filename, [op], revision=revision)
2235
+
2236
+
2237
+ @defs.command("add-rule")
2238
+ @click.argument("filename")
2239
+ @click.argument("format_id")
2240
+ @click.option("--target", required=True, help="Rule @target selector (see `deepcell ref format`).")
2241
+ @click.option("--bg-color", "background_color", default=None, help="Hex color '#RRGGBB' for backgroundColor.")
2242
+ @click.option("--font-color", "font_color", default=None, help="Hex color '#RRGGBB' for fontColor.")
2243
+ @click.option("--font-weight", "font_weight", default=None, help="e.g. 'bold' / 'normal'.")
2244
+ @click.option("--font-style", "font_style", default=None, help="e.g. 'italic' / 'normal'.")
2245
+ @click.option("--font-name", "font_name", default=None, help="Font family name.")
2246
+ @click.option("--font-size", "font_size", type=float, default=None, help="Font size in points.")
2247
+ @click.option("--text-align", "text_align", default=None, help="'left' | 'center' | 'right'.")
2248
+ @click.option("--indent", type=int, default=None, help="Indent character count.")
2249
+ @click.option("--number-format", "number_format", default=None, help="Excel-style pattern, e.g. '#,##0'.")
2250
+ @click.option("--border-top", default=None, help="e.g. 'thin #000000' (style + optional hex color).")
2251
+ @click.option("--border-bottom", default=None, help="Bottom border, e.g. 'thin #000000' (style + optional hex color).")
2252
+ @click.option("--border-left", default=None, help="Left border, e.g. 'thin #000000' (style + optional hex color).")
2253
+ @click.option("--border-right", default=None, help="Right border, e.g. 'thin #000000' (style + optional hex color).")
2254
+ @click.option("--index", type=int, default=None, help="Insert position (omit to append).")
2255
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
2256
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2257
+ @pass_ctx
2258
+ def add_rule(
2259
+ ctx: Ctx,
2260
+ filename: str,
2261
+ format_id: str,
2262
+ target: str,
2263
+ background_color: str | None,
2264
+ font_color: str | None,
2265
+ font_weight: str | None,
2266
+ font_style: str | None,
2267
+ font_name: str | None,
2268
+ font_size: float | None,
2269
+ text_align: str | None,
2270
+ indent: int | None,
2271
+ number_format: str | None,
2272
+ border_top: str | None,
2273
+ border_bottom: str | None,
2274
+ border_left: str | None,
2275
+ border_right: str | None,
2276
+ index: int | None,
2277
+ revision: str | None,
2278
+ workspace_slug: str | None,
2279
+ ) -> None:
2280
+ """Add a <Rule> to an existing <Format>."""
2281
+ slug = workspace_slug or ctx.require_workspace()
2282
+ rule: dict[str, Any] = {"target": target}
2283
+ for key, val in (
2284
+ ("background_color", background_color),
2285
+ ("font_color", font_color),
2286
+ ("font_weight", font_weight),
2287
+ ("font_style", font_style),
2288
+ ("font_name", font_name),
2289
+ ("font_size", font_size),
2290
+ ("text_align", text_align),
2291
+ ("indent", indent),
2292
+ ("number_format", number_format),
2293
+ ):
2294
+ if val is not None:
2295
+ rule[key] = val
2296
+ border = {}
2297
+ if border_top is not None:
2298
+ border["top"] = border_top
2299
+ if border_bottom is not None:
2300
+ border["bottom"] = border_bottom
2301
+ if border_left is not None:
2302
+ border["left"] = border_left
2303
+ if border_right is not None:
2304
+ border["right"] = border_right
2305
+ if border:
2306
+ rule["border"] = border
2307
+ op: dict[str, Any] = {"kind": "add_rule", "formatId": format_id, "rule": rule}
2308
+ if index is not None:
2309
+ op["index"] = index
2310
+ _apply(ctx, slug, filename, [op], revision=revision)
2311
+
2312
+
2313
+ @defs.command("set-format")
2314
+ @click.argument("filename")
2315
+ @click.option("--sheet-id", "sheet_id", required=True, help="Sheet id of the cell.")
2316
+ @click.option("--block-id", "block_id", default=None, help="Governing block id (resolves @formatRef).")
2317
+ @click.option("--item-ref", "item_ref", required=True, help="Item ref of the cell.")
2318
+ @click.option("--context-ref", "context_ref", default=None, help="Context ref (required for cell/column scope).")
2319
+ @click.option("--scope", type=click.Choice(["cell", "item", "context"]), required=True,
2320
+ help="cell:ITEM:CONTEXT | item:ITEM | context:CONTEXT")
2321
+ @click.option("--number-format", "number_format", default=None, help="Excel pattern, e.g. '#,##0'.")
2322
+ @click.option("--font-name", "font_name", default=None, help="Font family name.")
2323
+ @click.option(
2324
+ "--font-size", "font_size", type=float, default=None, help="Font size in points."
2325
+ )
2326
+ @click.option("--bold/--no-bold", "bold", default=None, help="Set/clear bold.")
2327
+ @click.option("--italic/--no-italic", "italic", default=None, help="Set/clear italic.")
2328
+ @click.option("--fg-color", "fg_color", default=None, help="Text hex '#RRGGBB'.")
2329
+ @click.option("--bg-color", "bg_color", default=None, help="Fill hex '#RRGGBB'.")
2330
+ @click.option("--text-align", "text_align", default=None, help="'left'|'center'|'right'.")
2331
+ @click.option("--indent", type=int, default=None, help="Indent character count.")
2332
+ @click.option("--border-top", default=None, help="e.g. 'thin #000000'.")
2333
+ @click.option("--border-bottom", default=None, help="Bottom border, e.g. 'thin #000000' (style + optional hex color).")
2334
+ @click.option("--border-left", default=None, help="Left border, e.g. 'thin #000000' (style + optional hex color).")
2335
+ @click.option("--border-right", default=None, help="Right border, e.g. 'thin #000000' (style + optional hex color).")
2336
+ @click.option("--clear", multiple=True, help="Token name to revert to inherit (repeatable).")
2337
+ @click.option("--clear-all", "clear_all", is_flag=True, help="Delete the target's rule entirely.")
2338
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
2339
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2340
+ @pass_ctx
2341
+ def set_format(
2342
+ ctx: Ctx,
2343
+ filename: str,
2344
+ sheet_id: str,
2345
+ block_id: str | None,
2346
+ item_ref: str,
2347
+ context_ref: str | None,
2348
+ scope: str,
2349
+ number_format: str | None,
2350
+ font_name: str | None,
2351
+ font_size: float | None,
2352
+ bold: bool | None,
2353
+ italic: bool | None,
2354
+ fg_color: str | None,
2355
+ bg_color: str | None,
2356
+ text_align: str | None,
2357
+ indent: int | None,
2358
+ border_top: str | None,
2359
+ border_bottom: str | None,
2360
+ border_left: str | None,
2361
+ border_right: str | None,
2362
+ clear: tuple[str, ...],
2363
+ clear_all: bool,
2364
+ revision: str | None,
2365
+ workspace_slug: str | None,
2366
+ ) -> None:
2367
+ """Set cell/row/column formatting (resolves the governing <Format>)."""
2368
+ slug = workspace_slug or ctx.require_workspace()
2369
+ tokens: dict[str, Any] = {}
2370
+ for key, val in (
2371
+ ("number_format", number_format),
2372
+ ("font_name", font_name),
2373
+ ("font_size", font_size),
2374
+ ("bold", bold),
2375
+ ("italic", italic),
2376
+ ("fg_color", fg_color),
2377
+ ("bg_color", bg_color),
2378
+ ("text_align", text_align),
2379
+ ("indent", indent),
2380
+ ):
2381
+ if val is not None:
2382
+ tokens[key] = val
2383
+ border = {}
2384
+ for side, val in (("top", border_top), ("bottom", border_bottom),
2385
+ ("left", border_left), ("right", border_right)):
2386
+ if val is not None:
2387
+ border[side] = val
2388
+ if border:
2389
+ tokens["border"] = border
2390
+ op: dict[str, Any] = {
2391
+ "kind": "set_format",
2392
+ "sheetId": sheet_id,
2393
+ "blockId": block_id,
2394
+ "itemRef": item_ref,
2395
+ "contextRef": context_ref,
2396
+ "scope": scope,
2397
+ "tokens": tokens,
2398
+ }
2399
+ if clear:
2400
+ op["clear"] = list(clear)
2401
+ if clear_all:
2402
+ op["clearAll"] = True
2403
+ _apply(ctx, slug, filename, [op], revision=revision)
2404
+
2405
+
2406
+ @defs.command("delete-rule")
2407
+ @click.argument("filename")
2408
+ @click.argument("format_id")
2409
+ @click.option("--index", type=int, default=None, help="Delete the rule at this position.")
2410
+ @click.option("--target", default=None, help="Delete the first rule with this @target.")
2411
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
2412
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2413
+ @pass_ctx
2414
+ def delete_rule(
2415
+ ctx: Ctx,
2416
+ filename: str,
2417
+ format_id: str,
2418
+ index: int | None,
2419
+ target: str | None,
2420
+ revision: str | None,
2421
+ workspace_slug: str | None,
2422
+ ) -> None:
2423
+ """Delete a <Rule> by index or by @target.
2424
+
2425
+ Rules are positional, so deleting by --index shifts every later rule up
2426
+ one — read the current order out of `deepcell cat FILENAME` before
2427
+ deleting by index. Pass --dry-run to validate without persisting.
2428
+ """
2429
+ slug = workspace_slug or ctx.require_workspace()
2430
+ if index is None and target is None:
2431
+ raise click.UsageError("Provide --index or --target.")
2432
+ op: dict[str, Any] = {"kind": "delete_rule", "formatId": format_id}
2433
+ if index is not None:
2434
+ op["index"] = index
2435
+ if target is not None:
2436
+ op["target"] = target
2437
+ _apply(ctx, slug, filename, [op], revision=revision)
2438
+
2439
+
2440
+ # ── Sensitivity block ops ──────────────────────────────────
2441
+
2442
+
2443
+ def _load_spec(spec_file: Any) -> dict[str, Any]:
2444
+ """Load and validate a sensitivity spec JSON file ({"axes": [...], "outputs": [...]})."""
2445
+ try:
2446
+ spec = json.load(spec_file)
2447
+ except json.JSONDecodeError as exc:
2448
+ raise click.UsageError(f"Spec file is not valid JSON: {exc}") from exc
2449
+ if not isinstance(spec, dict) or "axes" not in spec or "outputs" not in spec:
2450
+ raise click.UsageError('Spec file must be a JSON object with "axes" and "outputs".')
2451
+ return spec
2452
+
2453
+
2454
+ @defs.command("add-sensitivity")
2455
+ @click.argument("filename")
2456
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet to add the block to.")
2457
+ @click.option("--name", required=True, help="Display name of the sensitivity block.")
2458
+ @click.option(
2459
+ "--index",
2460
+ type=int,
2461
+ default=None,
2462
+ help="Position among sheet blocks (omit to append at end).",
2463
+ )
2464
+ @click.option("--format-ref", "format_ref", default=None, help="FormatDefinitions id.")
2465
+ @click.option(
2466
+ "--spec-file",
2467
+ "spec_file",
2468
+ required=True,
2469
+ type=click.File("r"),
2470
+ help='JSON with {"axes": [...], "outputs": [...]} (use "-" for stdin).',
2471
+ )
2472
+ @click.option(
2473
+ "--revision", default=None, help="Expected revision SHA for optimistic locking."
2474
+ )
2475
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2476
+ @pass_ctx
2477
+ def add_sensitivity(
2478
+ ctx: Ctx,
2479
+ filename: str,
2480
+ sheet_id: str,
2481
+ name: str,
2482
+ index: int | None,
2483
+ format_ref: str | None,
2484
+ spec_file: Any,
2485
+ revision: str | None,
2486
+ workspace_slug: str | None,
2487
+ ) -> None:
2488
+ """Add a sensitivity block (data table / scenario comparison).
2489
+
2490
+ The --spec-file JSON holds the axes and the outputs they drive, e.g.:
2491
+
2492
+ \b
2493
+ {"axes": [{"axis": "col", "axisType": "input", "itemRef": "wacc",
2494
+ "contextRef": "FY24", "points": ["0.08", "0.10"]}],
2495
+ "outputs": [{"itemRef": "npv", "contextRef": "FY24"}]}
2496
+
2497
+ An axis and an output each name ONE cell, and a cell is five dimensions.
2498
+ Add "dimFilter": "geo:na" to either when the item it names is
2499
+ dimension-sliced — the same spelling <Block dimFilter> uses. Without it the
2500
+ coordinate resolves to no cell: the grid renders blank and the block earns
2501
+ a sensitivity_unpinned_dims warning naming the member to pin.
2502
+
2503
+ See `deepcell ref op/add_sensitivity_block` for the spec semantics.
2504
+ """
2505
+ slug = workspace_slug or ctx.require_workspace()
2506
+ spec = _load_spec(spec_file)
2507
+ op: dict[str, Any] = {
2508
+ "kind": "add_sensitivity_block",
2509
+ "sheetId": sheet_id,
2510
+ "name": name,
2511
+ "axes": spec["axes"],
2512
+ "outputs": spec["outputs"],
2513
+ }
2514
+ if index is not None:
2515
+ op["index"] = index
2516
+ if format_ref is not None:
2517
+ op["formatRef"] = format_ref
2518
+ _apply(ctx, slug, filename, [op], revision=revision)
2519
+
2520
+
2521
+ @defs.command("update-sensitivity")
2522
+ @click.argument("filename")
2523
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
2524
+ @click.option("--block", "block_id", required=True, help="blockId of the block to edit.")
2525
+ @click.option("--name", default=None, help="New display name.")
2526
+ @click.option("--format-ref", "format_ref", default=None, help="New FormatDefinitions id.")
2527
+ @click.option(
2528
+ "--spec-file",
2529
+ "spec_file",
2530
+ default=None,
2531
+ type=click.File("r"),
2532
+ help='JSON with {"axes": [...], "outputs": [...]} to replace both.',
2533
+ )
2534
+ @click.option(
2535
+ "--revision", default=None, help="Expected revision SHA for optimistic locking."
2536
+ )
2537
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2538
+ @pass_ctx
2539
+ def update_sensitivity(
2540
+ ctx: Ctx,
2541
+ filename: str,
2542
+ sheet_id: str,
2543
+ block_id: str,
2544
+ name: str | None,
2545
+ format_ref: str | None,
2546
+ spec_file: Any,
2547
+ revision: str | None,
2548
+ workspace_slug: str | None,
2549
+ ) -> None:
2550
+ """Edit a sensitivity block's name, format, or axes/outputs.
2551
+
2552
+ Pass --spec-file to replace both axes and outputs together. Omitting
2553
+ --spec-file patches only name/formatRef without touching axes or outputs.
2554
+ Provide at least one of --name / --format-ref / --spec-file.
2555
+ """
2556
+ slug = workspace_slug or ctx.require_workspace()
2557
+ op: dict[str, Any] = {
2558
+ "kind": "update_sensitivity_block",
2559
+ "sheetId": sheet_id,
2560
+ "blockId": block_id,
2561
+ }
2562
+ if name is not None:
2563
+ op["name"] = name
2564
+ if format_ref is not None:
2565
+ op["formatRef"] = format_ref
2566
+ if spec_file is not None:
2567
+ spec = _load_spec(spec_file)
2568
+ op["axes"] = spec["axes"]
2569
+ op["outputs"] = spec["outputs"]
2570
+ if not any(k in op for k in ("name", "formatRef", "axes")):
2571
+ raise click.UsageError("Provide at least one of --name / --format-ref / --spec-file.")
2572
+ _apply(ctx, slug, filename, [op], revision=revision)
2573
+
2574
+
2575
+ # ── Presentation block attributes ──────────────────────────
2576
+
2577
+
2578
+ @defs.command("set-block-attrs")
2579
+ @click.argument("filename")
2580
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
2581
+ @click.option("--block", "block_id", required=True, help="blockId of the block to edit.")
2582
+ @click.option(
2583
+ "--format-ref",
2584
+ "format_ref",
2585
+ default=None,
2586
+ help=(
2587
+ "FormatDefinitions id this block resolves against. Pass an empty "
2588
+ "string to clear it back to the document default (the Format named "
2589
+ "default_format, else the first one defined). See "
2590
+ "`deepcell ref format`."
2591
+ ),
2592
+ )
2593
+ @click.option(
2594
+ "--status-expansion",
2595
+ "status_expansion",
2596
+ type=click.Choice(["none", "columns", "series"]),
2597
+ default=None,
2598
+ help=(
2599
+ "Side-by-side status-column layout. 'columns' lays each status in "
2600
+ "--status-refs out as its own run of context columns; 'series' is the "
2601
+ "chart form, one series per status; 'none' (default) keeps the "
2602
+ "single-column-per-context layout."
2603
+ ),
2604
+ )
2605
+ @click.option(
2606
+ "--status-refs",
2607
+ "status_refs",
2608
+ default=None,
2609
+ help=(
2610
+ "Whitespace/comma-separated status ids to expand into columns "
2611
+ '(e.g. "actual budget"). Required for --status-expansion=columns.'
2612
+ ),
2613
+ )
2614
+ @click.option(
2615
+ "--dim-expansion",
2616
+ "dim_expansion",
2617
+ type=click.Choice(["none", "rows", "series"]),
2618
+ default=None,
2619
+ help=(
2620
+ "Custom-dimension member sub-rows. 'rows' lays each member of "
2621
+ "--dim-ref out as a contiguous sub-row under its item; 'series' is "
2622
+ "the chart form, one series per member; 'none' (default) keeps the "
2623
+ "one-row-per-item layout."
2624
+ ),
2625
+ )
2626
+ @click.option(
2627
+ "--dim-ref",
2628
+ "dim_ref",
2629
+ default=None,
2630
+ help=(
2631
+ "Dimension id whose members are expanded into sub-rows "
2632
+ '(e.g. "geography"). Required for --dim-expansion=rows.'
2633
+ ),
2634
+ )
2635
+ @click.option(
2636
+ "--dim-expansion-cap",
2637
+ "dim_expansion_cap",
2638
+ type=click.IntRange(min=1),
2639
+ default=None,
2640
+ help=(
2641
+ "Max members to expand into sub-rows before falling back to a single "
2642
+ "row (positive int; default 24). Used with --dim-expansion=rows."
2643
+ ),
2644
+ )
2645
+ @click.option(
2646
+ "--dim-member-refs",
2647
+ "dim_member_refs",
2648
+ default=None,
2649
+ help=(
2650
+ "Whitespace/comma-separated member ids of --dim-ref, order preserved. "
2651
+ "With --dim-expansion it selects which members expand; without one, a "
2652
+ "single id pins that member for every cell the block reads — how a "
2653
+ "chart plots one slice of a dimension-sliced item."
2654
+ ),
2655
+ )
2656
+ @click.option(
2657
+ "--dim-filter",
2658
+ "dim_filter",
2659
+ default=None,
2660
+ help=(
2661
+ "Pin the dimensions the block does NOT expand, one member each: "
2662
+ "'region:na;cohort:c1'. Same dim:member spelling and ';' separator as "
2663
+ "customDimensions on a Value, so a stored coordinate can be copied "
2664
+ "across. A block expands one dimension (--dim-ref), so this is what "
2665
+ "makes a cell keyed on two or more custom dimensions addressable."
2666
+ ),
2667
+ )
2668
+ @click.option(
2669
+ "--scenario-expansion",
2670
+ "scenario_expansion",
2671
+ type=click.Choice(["none", "columns", "series"]),
2672
+ default=None,
2673
+ help=(
2674
+ "Side-by-side scenario-column layout. 'columns' lays each scenario in "
2675
+ "--scenario-refs out as its own run of context columns; 'series' is "
2676
+ "the chart form, one series per scenario; 'none' (default) keeps the "
2677
+ "single-column-per-context layout."
2678
+ ),
2679
+ )
2680
+ @click.option(
2681
+ "--scenario-refs",
2682
+ "scenario_refs",
2683
+ default=None,
2684
+ help=(
2685
+ "Whitespace/comma-separated scenario ids to expand into columns "
2686
+ '(e.g. "Base Bull"). Required for --scenario-expansion=columns.'
2687
+ ),
2688
+ )
2689
+ @click.option(
2690
+ "--chart-type",
2691
+ "chart_type",
2692
+ type=click.Choice(
2693
+ [
2694
+ "bar", "bar_stacked", "bar_horizontal", "line", "area", "pie",
2695
+ "donut", "waterfall", "range_bar", "scatter",
2696
+ ]
2697
+ ),
2698
+ default=None,
2699
+ help=(
2700
+ "Chart type (chart blocks only). 'waterfall' bridges deltas between "
2701
+ "declared totals; 'range_bar' draws a low..high band per category "
2702
+ "(the football field)."
2703
+ ),
2704
+ )
2705
+ @click.option(
2706
+ "--category-axis",
2707
+ "category_axis",
2708
+ type=click.Choice(["context", "item"]),
2709
+ default=None,
2710
+ help="Which dimension is the category axis (chart blocks only).",
2711
+ )
2712
+ @click.option(
2713
+ "--total-item-refs",
2714
+ "total_item_refs",
2715
+ default=None,
2716
+ help=(
2717
+ "Whitespace/comma-separated refs naming the plotted categories that "
2718
+ "rest on the baseline instead of floating on the running sum "
2719
+ "(--chart-type=waterfall only). Declared, never inferred: omit it and "
2720
+ "every step is a delta."
2721
+ ),
2722
+ )
2723
+ @click.option(
2724
+ "--revision", default=None, help="Expected revision SHA for optimistic locking."
2725
+ )
2726
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2727
+ @pass_ctx
2728
+ def set_block_attrs(
2729
+ ctx: Ctx,
2730
+ filename: str,
2731
+ sheet_id: str,
2732
+ block_id: str,
2733
+ format_ref: str | None,
2734
+ status_expansion: str | None,
2735
+ status_refs: str | None,
2736
+ dim_expansion: str | None,
2737
+ dim_ref: str | None,
2738
+ dim_expansion_cap: int | None,
2739
+ dim_member_refs: str | None,
2740
+ dim_filter: str | None,
2741
+ scenario_expansion: str | None,
2742
+ scenario_refs: str | None,
2743
+ chart_type: str | None,
2744
+ category_axis: str | None,
2745
+ total_item_refs: str | None,
2746
+ revision: str | None,
2747
+ workspace_slug: str | None,
2748
+ ) -> None:
2749
+ """Set presentation-layout attributes on an existing block.
2750
+
2751
+ Statuses as side-by-side column runs — --status-expansion with
2752
+ --status-refs:
2753
+
2754
+ \b
2755
+ deepcell defs set-block-attrs model.deepcell --sheet income_statement \\
2756
+ --block b_main --status-expansion columns --status-refs "actual budget"
2757
+
2758
+ Each member of a custom dimension as a contiguous sub-row under its item —
2759
+ --dim-expansion with --dim-ref:
2760
+
2761
+ \b
2762
+ deepcell defs set-block-attrs model.deepcell --sheet income_statement \\
2763
+ --block b_main --dim-expansion rows --dim-ref geography \\
2764
+ --dim-expansion-cap 12
2765
+
2766
+ Each scenario as its own side-by-side column run — --scenario-expansion
2767
+ with --scenario-refs:
2768
+
2769
+ \b
2770
+ deepcell defs set-block-attrs model.deepcell --sheet income_statement \\
2771
+ --block b_main --scenario-expansion columns --scenario-refs "Base Bull"
2772
+
2773
+ Chart blocks: --chart-type / --category-axis, e.g. --chart-type line
2774
+ --category-axis context. A waterfall also takes --total-item-refs
2775
+ naming the steps that rest on the baseline, and a scatter reads its two
2776
+ series as x then y.
2777
+
2778
+ On a chart the expansions take 'series' instead of 'columns' / 'rows',
2779
+ laying that axis out as one series per member — measured against expected
2780
+ on one plot:
2781
+
2782
+ \b
2783
+ deepcell defs set-block-attrs model.deepcell --sheet readout \\
2784
+ --block counts_chart --status-expansion series \\
2785
+ --status-refs "predicted counted" --dim-ref phenotype \\
2786
+ --dim-member-refs dominant
2787
+
2788
+ --dim-member-refs on its own pins one member of --dim-ref, which is how a
2789
+ chart reads a single slice of a dimension-sliced item while some other
2790
+ axis supplies the series.
2791
+
2792
+ --format-ref names which <Format> the block resolves against, for any block
2793
+ type. Without one the block takes the document default — the Format named
2794
+ `default_format`, else the first one defined — so this is the flag that
2795
+ puts a specific Format on a specific block when a document holds several.
2796
+
2797
+ Only the flags you provide are written (key-presence patch): passing
2798
+ --status-refs on its own leaves an existing statusExpansion untouched, and
2799
+ vice versa. Provide at least one of --format-ref / --status-expansion /
2800
+ --status-refs /
2801
+ --dim-expansion / --dim-ref / --dim-expansion-cap / --dim-member-refs /
2802
+ --scenario-expansion / --scenario-refs / --chart-type / --category-axis.
2803
+
2804
+ See `deepcell ref op/set_presentation_block_attrs` for the expansion
2805
+ and pin semantics.
2806
+ """
2807
+ slug = workspace_slug or ctx.require_workspace()
2808
+ op: dict[str, Any] = {
2809
+ "kind": "set_presentation_block_attrs",
2810
+ "sheetId": sheet_id,
2811
+ "blockId": block_id,
2812
+ }
2813
+ if format_ref is not None:
2814
+ op["formatRef"] = format_ref
2815
+ if status_expansion is not None:
2816
+ op["statusExpansion"] = status_expansion
2817
+ if status_refs is not None:
2818
+ op["statusRefs"] = status_refs
2819
+ if dim_expansion is not None:
2820
+ op["dimExpansion"] = dim_expansion
2821
+ if dim_ref is not None:
2822
+ op["dimRef"] = dim_ref
2823
+ if dim_expansion_cap is not None:
2824
+ op["dimExpansionCap"] = dim_expansion_cap
2825
+ if dim_member_refs is not None:
2826
+ op["dimMemberRefs"] = dim_member_refs
2827
+ if dim_filter is not None:
2828
+ op["dimFilter"] = dim_filter
2829
+ if scenario_expansion is not None:
2830
+ op["scenarioExpansion"] = scenario_expansion
2831
+ if scenario_refs is not None:
2832
+ op["scenarioRefs"] = scenario_refs
2833
+ if chart_type is not None:
2834
+ op["chartType"] = chart_type
2835
+ if category_axis is not None:
2836
+ op["categoryAxis"] = category_axis
2837
+ if total_item_refs is not None:
2838
+ op["totalItemRefs"] = total_item_refs
2839
+ if not any(
2840
+ k in op
2841
+ for k in (
2842
+ "formatRef",
2843
+ "statusExpansion",
2844
+ "statusRefs",
2845
+ "dimExpansion",
2846
+ "dimRef",
2847
+ "dimExpansionCap",
2848
+ "dimMemberRefs",
2849
+ "dimFilter",
2850
+ "scenarioExpansion",
2851
+ "scenarioRefs",
2852
+ "chartType",
2853
+ "categoryAxis",
2854
+ "totalItemRefs",
2855
+ )
2856
+ ):
2857
+ raise click.UsageError(
2858
+ "Provide at least one of --format-ref / --status-expansion / "
2859
+ "--status-refs / "
2860
+ "--dim-expansion / --dim-ref / --dim-expansion-cap / "
2861
+ "--dim-member-refs / --dim-filter / --scenario-expansion / "
2862
+ "--scenario-refs / "
2863
+ "--chart-type / --category-axis / --total-item-refs."
2864
+ )
2865
+ _apply(ctx, slug, filename, [op], revision=revision)
2866
+
2867
+
2868
+ @defs.command("add-block")
2869
+ @click.argument("filename")
2870
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet to add the block to.")
2871
+ @click.option(
2872
+ "--block-type",
2873
+ "block_type",
2874
+ required=True,
2875
+ help=(
2876
+ "One of: table, chart, sensitivity, key_value, text. The server "
2877
+ "stores any string, but the renderer skips a block whose type it "
2878
+ "does not know, so anything else is written and never drawn."
2879
+ ),
2880
+ )
2881
+ @click.option("--name", required=True, help="Display name / section heading.")
2882
+ @click.option(
2883
+ "--item-orders",
2884
+ "item_orders",
2885
+ default=None,
2886
+ help=(
2887
+ "Comma-separated rows, in order: item ids, @order values, or order "
2888
+ "ranges (e.g. 'revenue,cogs,gross_profit' or '4000-4099'). Stored "
2889
+ "verbatim as @itemOrders; the reader resolves each token — a number "
2890
+ "or a range matches @order, anything else matches an itemId. Omit "
2891
+ "and the block renders empty — see the note above."
2892
+ ),
2893
+ )
2894
+ @click.option(
2895
+ "--context-refs",
2896
+ "context_refs",
2897
+ default=None,
2898
+ help=(
2899
+ "Comma-separated context ids forming the block's columns, in order "
2900
+ "(e.g. 'FY2024,FY2025'). Omit to show every context."
2901
+ ),
2902
+ )
2903
+ @click.option(
2904
+ "--index", type=int, default=None,
2905
+ help="Insertion index within the sheet (default: append at the end).",
2906
+ )
2907
+ @click.option(
2908
+ "--format-ref",
2909
+ "format_ref",
2910
+ default=None,
2911
+ help=(
2912
+ "FormatDefinitions id this block resolves against. Omit and the block "
2913
+ "takes the document default (the Format named default_format, else the "
2914
+ "first one defined). See `deepcell ref format`."
2915
+ ),
2916
+ )
2917
+ @click.option(
2918
+ "--chart-type",
2919
+ "chart_type",
2920
+ type=click.Choice(
2921
+ [
2922
+ "bar", "bar_stacked", "bar_horizontal", "line", "area", "pie",
2923
+ "donut", "waterfall", "range_bar", "scatter",
2924
+ ]
2925
+ ),
2926
+ default=None,
2927
+ help=(
2928
+ "Chart type (chart blocks only). 'waterfall' bridges deltas between "
2929
+ "declared totals; 'range_bar' draws a low..high band per category "
2930
+ "(the football field)."
2931
+ ),
2932
+ )
2933
+ @click.option(
2934
+ "--category-axis",
2935
+ "category_axis",
2936
+ type=click.Choice(["context", "item"]),
2937
+ default=None,
2938
+ help="Which dimension is the category axis (chart blocks only).",
2939
+ )
2940
+ @click.option(
2941
+ "--total-item-refs",
2942
+ "total_item_refs",
2943
+ default=None,
2944
+ help=(
2945
+ "Whitespace/comma-separated refs naming the plotted categories that "
2946
+ "rest on the baseline instead of floating on the running sum "
2947
+ "(--chart-type=waterfall only). Declared, never inferred: omit it and "
2948
+ "every step is a delta."
2949
+ ),
2950
+ )
2951
+ @click.option(
2952
+ "--revision", default=None, help="Expected revision SHA for optimistic locking."
2953
+ )
2954
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
2955
+ @pass_ctx
2956
+ def add_block(
2957
+ ctx: Ctx,
2958
+ filename: str,
2959
+ sheet_id: str,
2960
+ block_type: str,
2961
+ name: str,
2962
+ item_orders: str | None,
2963
+ context_refs: str | None,
2964
+ index: int,
2965
+ format_ref: str | None,
2966
+ chart_type: str | None,
2967
+ category_axis: str | None,
2968
+ total_item_refs: str | None,
2969
+ revision: str | None,
2970
+ workspace_slug: str | None,
2971
+ ) -> None:
2972
+ """Add a presentation Block (table or chart) to a sheet.
2973
+
2974
+ Pass --item-orders. A table, chart, or key_value block that declares no
2975
+ item membership renders EMPTY, so a block added without it exists in the
2976
+ XML and is drawn nowhere. Rows and columns can also be changed afterwards
2977
+ with `deepcell defs add-axis-member` / `delete-axis-member`.
2978
+
2979
+ \b
2980
+ deepcell defs add-block model.deepcell --sheet s_main \\
2981
+ --block-type table --name "Income Statement" \\
2982
+ --item-orders revenue,cogs,gross_profit
2983
+
2984
+ \b
2985
+ deepcell defs add-block model.deepcell --sheet s_dash \\
2986
+ --block-type chart --name "Revenue Trend" --chart-type line \\
2987
+ --item-orders revenue --category-axis context
2988
+
2989
+ --format-ref names which <Format> the block resolves against. Omit it and
2990
+ the block takes the document default, which is the right answer for a file
2991
+ with one Format; name it when the file has several and this block needs a
2992
+ particular one. Changeable afterwards with `defs set-block-attrs`.
2993
+ """
2994
+ slug = workspace_slug or ctx.require_workspace()
2995
+ if block_type.strip() not in _RENDERABLE_BLOCK_TYPES:
2996
+ # The server accepts blockType opaquely, so a typo is not an error —
2997
+ # it lands a block the render pipeline silently skips. Say so at the
2998
+ # point of use rather than leaving the block invisible.
2999
+ echo_warning(
3000
+ f"block type {block_type!r} is not one of "
3001
+ f"{', '.join(sorted(_RENDERABLE_BLOCK_TYPES))} — the block will be "
3002
+ "stored but skipped when the document is rendered or exported."
3003
+ )
3004
+ op: dict[str, Any] = {
3005
+ "kind": "add_presentation_block",
3006
+ "sheetId": sheet_id,
3007
+ "blockType": block_type,
3008
+ "name": name,
3009
+ }
3010
+ # Omitted entirely when not given: the handler appends on a missing index.
3011
+ if index is not None:
3012
+ op["index"] = index
3013
+ if item_orders is not None:
3014
+ op["itemOrders"] = item_orders
3015
+ if context_refs is not None:
3016
+ op["contextRefs"] = context_refs
3017
+ if format_ref is not None:
3018
+ op["formatRef"] = format_ref
3019
+ if chart_type is not None:
3020
+ op["chartType"] = chart_type
3021
+ if category_axis is not None:
3022
+ op["categoryAxis"] = category_axis
3023
+ if total_item_refs is not None:
3024
+ op["totalItemRefs"] = total_item_refs
3025
+ _apply(ctx, slug, filename, [op], revision=revision)
3026
+
3027
+
3028
+ # ── Presentation sheets ────────────────────────────────────
3029
+ #
3030
+ # The sheet / block / axis-member ops were built for the web Configure form
3031
+ # and the viewer's "+ Add sheet" button, so none of them had a CLI command:
3032
+ # an agent could add a block but not the sheet to put it on, and could not
3033
+ # rename, reorder, or remove anything it created.
3034
+
3035
+
3036
+ @defs.command("add-sheet")
3037
+ @click.argument("filename")
3038
+ @click.option("--label", required=True, help="Sheet tab label, e.g. 'Dashboard'.")
3039
+ @click.option(
3040
+ "--index",
3041
+ type=click.IntRange(min=0),
3042
+ default=None,
3043
+ help="0-based position among the sheets (default: append at the end).",
3044
+ )
3045
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3046
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3047
+ @pass_ctx
3048
+ def add_sheet(
3049
+ ctx: Ctx,
3050
+ filename: str,
3051
+ label: str,
3052
+ index: int | None,
3053
+ revision: str | None,
3054
+ workspace_slug: str | None,
3055
+ ) -> None:
3056
+ """Add a presentation Sheet — a tab in the rendered document.
3057
+
3058
+ A sheet holds Blocks; add one with `deepcell defs add-block --sheet`.
3059
+ The generated sheetId comes back in this command's output.
3060
+ """
3061
+ slug = workspace_slug or ctx.require_workspace()
3062
+ op: dict[str, Any] = {"kind": "add_presentation_sheet", "sheetLabel": label}
3063
+ # Omitted when not given: the handler appends on a missing index.
3064
+ if index is not None:
3065
+ op["index"] = index
3066
+ _apply(ctx, slug, filename, [op], revision=revision)
3067
+
3068
+
3069
+ @defs.command("delete-sheet")
3070
+ @click.argument("filename")
3071
+ @click.argument("sheet_id")
3072
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3073
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3074
+ @pass_ctx
3075
+ def delete_sheet(
3076
+ ctx: Ctx,
3077
+ filename: str,
3078
+ sheet_id: str,
3079
+ revision: str | None,
3080
+ workspace_slug: str | None,
3081
+ ) -> None:
3082
+ """Delete a presentation Sheet and every Block on it.
3083
+
3084
+ Presentation-only: the ItemDefs and ContextDefs the blocks referenced
3085
+ stay, and so do their Values. Nothing else in the document is touched.
3086
+ """
3087
+ slug = workspace_slug or ctx.require_workspace()
3088
+ op = {"kind": "delete_presentation_sheet", "sheetId": sheet_id}
3089
+ _apply(ctx, slug, filename, [op], revision=revision)
3090
+
3091
+
3092
+ @defs.command("rename-sheet")
3093
+ @click.argument("filename")
3094
+ @click.argument("sheet_id")
3095
+ @click.argument("new_label")
3096
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3097
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3098
+ @pass_ctx
3099
+ def rename_sheet(
3100
+ ctx: Ctx,
3101
+ filename: str,
3102
+ sheet_id: str,
3103
+ new_label: str,
3104
+ revision: str | None,
3105
+ workspace_slug: str | None,
3106
+ ) -> None:
3107
+ """Change a Sheet's displayed tab label.
3108
+
3109
+ The sheetId is the stable identifier and does not change, so nothing
3110
+ that references the sheet needs repointing.
3111
+ """
3112
+ slug = workspace_slug or ctx.require_workspace()
3113
+ op = {
3114
+ "kind": "rename_presentation_sheet",
3115
+ "sheetId": sheet_id,
3116
+ "newLabel": new_label,
3117
+ }
3118
+ _apply(ctx, slug, filename, [op], revision=revision)
3119
+
3120
+
3121
+ @defs.command("reorder-sheets")
3122
+ @click.argument("filename")
3123
+ @click.argument("sheet_id")
3124
+ @click.option(
3125
+ "--to-index",
3126
+ "to_index",
3127
+ type=click.IntRange(min=0),
3128
+ required=True,
3129
+ help="Final 0-based position among the sheets. 0 = leftmost tab.",
3130
+ )
3131
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3132
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3133
+ @pass_ctx
3134
+ def reorder_sheets(
3135
+ ctx: Ctx,
3136
+ filename: str,
3137
+ sheet_id: str,
3138
+ to_index: int,
3139
+ revision: str | None,
3140
+ workspace_slug: str | None,
3141
+ ) -> None:
3142
+ """Move a Sheet to a new position in the tab order."""
3143
+ slug = workspace_slug or ctx.require_workspace()
3144
+ op = {
3145
+ "kind": "reorder_presentation_sheets",
3146
+ "sheetId": sheet_id,
3147
+ "toIndex": to_index,
3148
+ }
3149
+ _apply(ctx, slug, filename, [op], revision=revision)
3150
+
3151
+
3152
+ # ── Presentation blocks ────────────────────────────────────
3153
+
3154
+
3155
+ @defs.command("delete-block")
3156
+ @click.argument("filename")
3157
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
3158
+ @click.option("--block", "block_id", required=True, help="blockId to delete.")
3159
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3160
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3161
+ @pass_ctx
3162
+ def delete_block(
3163
+ ctx: Ctx,
3164
+ filename: str,
3165
+ sheet_id: str,
3166
+ block_id: str,
3167
+ revision: str | None,
3168
+ workspace_slug: str | None,
3169
+ ) -> None:
3170
+ """Delete a Block from a Sheet.
3171
+
3172
+ Refused while the block still has axis members — clear them first with
3173
+ `deepcell defs delete-axis-member`. Presentation-only: the items and
3174
+ contexts the block laid out keep their definitions and their Values.
3175
+ """
3176
+ slug = workspace_slug or ctx.require_workspace()
3177
+ op = {
3178
+ "kind": "delete_presentation_block",
3179
+ "sheetId": sheet_id,
3180
+ "blockId": block_id,
3181
+ }
3182
+ _apply(ctx, slug, filename, [op], revision=revision)
3183
+
3184
+
3185
+ @defs.command("rename-block")
3186
+ @click.argument("filename")
3187
+ @click.argument("new_name")
3188
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
3189
+ @click.option("--block", "block_id", required=True, help="blockId to rename.")
3190
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3191
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3192
+ @pass_ctx
3193
+ def rename_block(
3194
+ ctx: Ctx,
3195
+ filename: str,
3196
+ new_name: str,
3197
+ sheet_id: str,
3198
+ block_id: str,
3199
+ revision: str | None,
3200
+ workspace_slug: str | None,
3201
+ ) -> None:
3202
+ """Change a Block's display name — its section heading in the render.
3203
+
3204
+ \b
3205
+ deepcell defs rename-block model.deepcell "Income Statement" \\
3206
+ --sheet s_main --block block_ab12
3207
+ """
3208
+ slug = workspace_slug or ctx.require_workspace()
3209
+ op = {
3210
+ "kind": "rename_presentation_block",
3211
+ "sheetId": sheet_id,
3212
+ "blockId": block_id,
3213
+ "newName": new_name,
3214
+ }
3215
+ _apply(ctx, slug, filename, [op], revision=revision)
3216
+
3217
+
3218
+ @defs.command("reorder-block")
3219
+ @click.argument("filename")
3220
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
3221
+ @click.option("--block", "block_id", required=True, help="blockId to move.")
3222
+ @click.option(
3223
+ "--to-index",
3224
+ "to_index",
3225
+ type=click.IntRange(min=0),
3226
+ required=True,
3227
+ help="Final 0-based position among the sheet's blocks. 0 = topmost.",
3228
+ )
3229
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3230
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3231
+ @pass_ctx
3232
+ def reorder_block(
3233
+ ctx: Ctx,
3234
+ filename: str,
3235
+ sheet_id: str,
3236
+ block_id: str,
3237
+ to_index: int,
3238
+ revision: str | None,
3239
+ workspace_slug: str | None,
3240
+ ) -> None:
3241
+ """Move a Block to a new position within its Sheet.
3242
+
3243
+ Blocks render top to bottom in this order; every sibling's @order is
3244
+ renumbered to its new 1-based position.
3245
+ """
3246
+ slug = workspace_slug or ctx.require_workspace()
3247
+ op = {
3248
+ "kind": "reorder_presentation_block",
3249
+ "sheetId": sheet_id,
3250
+ "blockId": block_id,
3251
+ "toIndex": to_index,
3252
+ }
3253
+ _apply(ctx, slug, filename, [op], revision=revision)
3254
+
3255
+
3256
+ # ── Block axis members (the rows and columns of a block) ────
3257
+
3258
+
3259
+ _AXIS_HELP = (
3260
+ "Which axis to edit: 'rows' (the block's @itemOrders) or 'columns' "
3261
+ "(its @contextRefs)."
3262
+ )
3263
+
3264
+
3265
+ @defs.command("add-axis-member")
3266
+ @click.argument("filename")
3267
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
3268
+ @click.option("--block", "block_id", required=True, help="blockId to edit.")
3269
+ @click.option(
3270
+ "--axis", type=click.Choice(["rows", "columns"]), required=True, help=_AXIS_HELP
3271
+ )
3272
+ @click.option(
3273
+ "--member",
3274
+ type=click.Choice(["item", "context"]),
3275
+ required=True,
3276
+ help="What kind of id --ref is. Pair 'item' with rows, 'context' with columns.",
3277
+ )
3278
+ @click.option("--ref", required=True, help="The item id or context id to add.")
3279
+ @click.option(
3280
+ "--index",
3281
+ type=click.IntRange(min=0),
3282
+ default=None,
3283
+ help="0-based position on the axis (default: append at the end).",
3284
+ )
3285
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3286
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3287
+ @pass_ctx
3288
+ def add_axis_member(
3289
+ ctx: Ctx,
3290
+ filename: str,
3291
+ sheet_id: str,
3292
+ block_id: str,
3293
+ axis: str,
3294
+ member: str,
3295
+ ref: str,
3296
+ index: int | None,
3297
+ revision: str | None,
3298
+ workspace_slug: str | None,
3299
+ ) -> None:
3300
+ """Add a row or a column to a Block.
3301
+
3302
+ This is what makes a Block show anything: a block with no rows renders
3303
+ empty. `deepcell defs add-block --item-orders` sets the initial rows in
3304
+ one call; use this to add to them afterwards.
3305
+
3306
+ \b
3307
+ deepcell defs add-axis-member model.deepcell --sheet s_main \\
3308
+ --block block_ab12 --axis rows --member item --ref gross_profit
3309
+ """
3310
+ slug = workspace_slug or ctx.require_workspace()
3311
+ op: dict[str, Any] = {
3312
+ "kind": "add_axis_member",
3313
+ "sheetId": sheet_id,
3314
+ "blockId": block_id,
3315
+ "axis": axis,
3316
+ "member": member,
3317
+ "ref": ref,
3318
+ }
3319
+ # Omitted when not given: the handler appends on a missing index.
3320
+ if index is not None:
3321
+ op["index"] = index
3322
+ _apply(ctx, slug, filename, [op], revision=revision)
3323
+
3324
+
3325
+ @defs.command("delete-axis-member")
3326
+ @click.argument("filename")
3327
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
3328
+ @click.option("--block", "block_id", required=True, help="blockId to edit.")
3329
+ @click.option(
3330
+ "--axis", type=click.Choice(["rows", "columns"]), required=True, help=_AXIS_HELP
3331
+ )
3332
+ @click.option("--ref", required=True, help="The item id or context id to remove.")
3333
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3334
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3335
+ @pass_ctx
3336
+ def delete_axis_member(
3337
+ ctx: Ctx,
3338
+ filename: str,
3339
+ sheet_id: str,
3340
+ block_id: str,
3341
+ axis: str,
3342
+ ref: str,
3343
+ revision: str | None,
3344
+ workspace_slug: str | None,
3345
+ ) -> None:
3346
+ """Remove a row or a column from a Block.
3347
+
3348
+ Unwires presentation only — the ItemDef or ContextDef stays defined and
3349
+ its Values stay in the document; the block just stops laying it out. To
3350
+ delete the definition itself use `defs delete-item` / `delete-context`.
3351
+ """
3352
+ slug = workspace_slug or ctx.require_workspace()
3353
+ op = {
3354
+ "kind": "delete_axis_member",
3355
+ "sheetId": sheet_id,
3356
+ "blockId": block_id,
3357
+ "axis": axis,
3358
+ "ref": ref,
3359
+ }
3360
+ _apply(ctx, slug, filename, [op], revision=revision)
3361
+
3362
+
3363
+ @defs.command("reorder-axis-member")
3364
+ @click.argument("filename")
3365
+ @click.option("--sheet", "sheet_id", required=True, help="Sheet containing the block.")
3366
+ @click.option("--block", "block_id", required=True, help="blockId to edit.")
3367
+ @click.option(
3368
+ "--axis", type=click.Choice(["rows", "columns"]), required=True, help=_AXIS_HELP
3369
+ )
3370
+ @click.option("--ref", required=True, help="The item id or context id to move.")
3371
+ @click.option(
3372
+ "--to-index",
3373
+ "to_index",
3374
+ type=click.IntRange(min=0),
3375
+ required=True,
3376
+ help="Final 0-based position on the axis. 0 = first row / leftmost column.",
3377
+ )
3378
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3379
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3380
+ @pass_ctx
3381
+ def reorder_axis_member(
3382
+ ctx: Ctx,
3383
+ filename: str,
3384
+ sheet_id: str,
3385
+ block_id: str,
3386
+ axis: str,
3387
+ ref: str,
3388
+ to_index: int,
3389
+ revision: str | None,
3390
+ workspace_slug: str | None,
3391
+ ) -> None:
3392
+ """Move a row or a column to a new position within a Block.
3393
+
3394
+ Changes presentation order only. `defs reorder-item` moves the item in
3395
+ ItemDefinitions, which is a different thing — a block lays out only the
3396
+ members on its own axis, in its own order.
3397
+ """
3398
+ slug = workspace_slug or ctx.require_workspace()
3399
+ op = {
3400
+ "kind": "reorder_axis_member",
3401
+ "sheetId": sheet_id,
3402
+ "blockId": block_id,
3403
+ "axis": axis,
3404
+ "ref": ref,
3405
+ "toIndex": to_index,
3406
+ }
3407
+ _apply(ctx, slug, filename, [op], revision=revision)
3408
+
3409
+
3410
+ # ── Batch escape hatch ─────────────────────────────────────
3411
+
3412
+
3413
+ @defs.command("apply")
3414
+ @click.argument("filename")
3415
+ @click.option(
3416
+ "--ops-file",
3417
+ "ops_file",
3418
+ default=None,
3419
+ type=click.File("r"),
3420
+ help="JSON file with an `ops` array (use '-' for stdin).",
3421
+ )
3422
+ @click.option(
3423
+ "--ops",
3424
+ "ops_inline",
3425
+ default=None,
3426
+ help="Inline ops JSON (same shapes as --ops-file); mirrors the agent "
3427
+ "tool's `defs apply --ops '<json>'` form.",
3428
+ )
3429
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3430
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3431
+ @pass_ctx
3432
+ def apply(
3433
+ ctx: Ctx,
3434
+ filename: str,
3435
+ ops_file: Any,
3436
+ ops_inline: str | None,
3437
+ revision: str | None,
3438
+ workspace_slug: str | None,
3439
+ ) -> None:
3440
+ """Apply a batch of ops atomically.
3441
+
3442
+ \b
3443
+ The ops payload (--ops-file FILE, or inline via --ops '<json>') is a
3444
+ JSON document of either form:
3445
+ [{"kind": "add_calc", "defaults": {...}}, ...] # bare array
3446
+ {"ops": [{"kind": "add_item", "index": 0, ...}, ...]} # wrapped
3447
+
3448
+ \b
3449
+ See `deepcell guide generate/calcs` for calculation op shapes and
3450
+ `deepcell guide present/decks` for HTML Deck op shapes and a complete
3451
+ authoring example.
3452
+
3453
+ \b
3454
+ Pass --dry-run to pre-flight the batch: the server runs the complete
3455
+ pipeline (per-op validation, formula parse, post-apply cycle check +
3456
+ recompute) and reports success/errors without persisting anything —
3457
+ no commit, no revision bump.
3458
+
3459
+ \b
3460
+ Presentation axis-member ops (`add_axis_member` / `delete_axis_member` /
3461
+ `reorder_axis_member`) require an explicit `blockId` field — there is no
3462
+ implicit primary block. See `deepcell ref op/add_axis_member`.
3463
+ """
3464
+ slug = workspace_slug or ctx.require_workspace()
3465
+ if (ops_file is None) == (ops_inline is None):
3466
+ raise click.UsageError("Provide exactly one of --ops-file or --ops.")
3467
+ try:
3468
+ parsed = json.load(ops_file) if ops_file is not None else json.loads(ops_inline)
3469
+ except json.JSONDecodeError as exc:
3470
+ raise click.UsageError(f"Ops JSON does not parse — {exc}") from exc
3471
+ if isinstance(parsed, list):
3472
+ ops = parsed
3473
+ elif isinstance(parsed, dict) and isinstance(parsed.get("ops"), list):
3474
+ ops = parsed["ops"]
3475
+ else:
3476
+ raise click.UsageError(
3477
+ 'Ops must be a JSON array or {"ops": [...]} object.'
3478
+ )
3479
+ if not ops:
3480
+ raise click.UsageError("No ops to apply.")
3481
+ _apply(ctx, slug, filename, ops, revision=revision)
3482
+
3483
+
3484
+ # --- <Document> prose sections ----------------------------------------------
3485
+ #
3486
+ # The BODY is edited by `deepcell doc set-body` / `doc patch-body`, not here:
3487
+ # a whole markdown document is the wrong shape for a flag. These three manage
3488
+ # the section's existence and its attributes.
3489
+
3490
+
3491
+ @defs.command("add-doc")
3492
+ @click.argument("filename")
3493
+ @click.option("--doc-id", "doc_id", default=None,
3494
+ help="Document identifier (@docId). Must match [A-Za-z0-9_.-]+ so a "
3495
+ "deepcell:doc/<id> reference to it parses back.")
3496
+ @click.option("--name", default=None, help="Display title.")
3497
+ @click.option("--lang", default=None, help="BCP-47 language tag, e.g. 'en' or 'zh'.")
3498
+ @click.option("--body-file", type=click.Path(exists=True, dir_okay=False), default=None,
3499
+ help="File holding the initial body (else empty).")
3500
+ @click.option("--notation", type=click.Choice(["markdown", "text"]), default=None,
3501
+ help="What the body IS. A new document is markdown unless you "
3502
+ "say 'text', which also stops anchors being stamped into it.")
3503
+ @click.option("--index", type=int, default=None, help="Position among documents.")
3504
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3505
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3506
+ @pass_ctx
3507
+ def add_doc(
3508
+ ctx: Ctx,
3509
+ filename: str,
3510
+ doc_id: str | None,
3511
+ name: str | None,
3512
+ lang: str | None,
3513
+ body_file: str | None,
3514
+ notation: str | None,
3515
+ index: int | None,
3516
+ revision: str | None,
3517
+ workspace_slug: str | None,
3518
+ ) -> None:
3519
+ """Add a <Document> — the prose that ships with the model.
3520
+
3521
+ Every number in that prose should be a link, not a typed figure: a linked
3522
+ value re-renders from the model and cannot go stale (`deepcell rules R15`).
3523
+ See `deepcell guide present/prose`.
3524
+
3525
+ A new document is `markdown` unless `--notation text` says otherwise, and
3526
+ is stamped with `{#id}` anchors at birth so every block is addressable.
3527
+ `--notation text` stores the bytes as they are and stamps nothing.
3528
+ """
3529
+ slug = workspace_slug or ctx.require_workspace()
3530
+ body = ""
3531
+ if body_file:
3532
+ with open(body_file, "r", encoding="utf-8") as handle:
3533
+ body = handle.read()
3534
+ op: dict = {"kind": "add_presentation_document", "body": body}
3535
+ if doc_id:
3536
+ op["docId"] = doc_id
3537
+ if name:
3538
+ op["name"] = name
3539
+ if lang:
3540
+ op["lang"] = lang
3541
+ if notation:
3542
+ op["notation"] = notation
3543
+ if index is not None:
3544
+ op["index"] = index
3545
+ _apply(ctx, slug, filename, [op], revision=revision)
3546
+
3547
+
3548
+ @defs.command("update-doc")
3549
+ @click.argument("filename")
3550
+ @click.argument("doc_id")
3551
+ @click.option("--name", default=None, help="New display title.")
3552
+ @click.option("--lang", default=None, help="New BCP-47 language tag.")
3553
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3554
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3555
+ @pass_ctx
3556
+ def update_doc(
3557
+ ctx: Ctx,
3558
+ filename: str,
3559
+ doc_id: str,
3560
+ name: str | None,
3561
+ lang: str | None,
3562
+ revision: str | None,
3563
+ workspace_slug: str | None,
3564
+ ) -> None:
3565
+ """Update a Document's attributes. Use `doc set-body` for its text."""
3566
+ slug = workspace_slug or ctx.require_workspace()
3567
+ op: dict = {"kind": "update_presentation_document", "docId": doc_id}
3568
+ if name:
3569
+ op["name"] = name
3570
+ if lang:
3571
+ op["lang"] = lang
3572
+ _apply(ctx, slug, filename, [op], revision=revision)
3573
+
3574
+
3575
+ @defs.command("delete-doc")
3576
+ @click.argument("filename")
3577
+ @click.argument("doc_id")
3578
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3579
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3580
+ @pass_ctx
3581
+ def delete_doc(
3582
+ ctx: Ctx,
3583
+ filename: str,
3584
+ doc_id: str,
3585
+ revision: str | None,
3586
+ workspace_slug: str | None,
3587
+ ) -> None:
3588
+ """Delete a Document and its body.
3589
+
3590
+ Nothing else in the file depends on a document — it is a presentation
3591
+ surface — but references TO it from other documents or deck bindings become
3592
+ dangling, which `deepcell doc links --unresolved` reports.
3593
+ """
3594
+ slug = workspace_slug or ctx.require_workspace()
3595
+ _apply(
3596
+ ctx, slug, filename,
3597
+ [{"kind": "delete_presentation_document", "docId": doc_id}],
3598
+ revision=revision,
3599
+ )
3600
+
3601
+
3602
+ # ── Source ops (docs/source-and-links-design.md §8) ──────────────────────────
3603
+ #
3604
+ # `<DataSource>` never had a defs op and `edit --batch` writes values only, so
3605
+ # a cell an agent populated through the normal path was source-less by
3606
+ # construction. These close that.
3607
+
3608
+
3609
+ def _cites_row(
3610
+ item_refs: str | None,
3611
+ context_refs: str | None,
3612
+ status_ref: str | None,
3613
+ scenario_ref: str | None,
3614
+ custom_dimensions: str | None,
3615
+ at: str | None,
3616
+ ) -> dict[str, Any] | None:
3617
+ """One `<Cites>` row from the flag set, or None when nothing was named."""
3618
+ row: dict[str, Any] = {}
3619
+ if item_refs:
3620
+ row["item_refs"] = [s.strip() for s in item_refs.split(",") if s.strip()]
3621
+ if context_refs:
3622
+ row["context_refs"] = [s.strip() for s in context_refs.split(",") if s.strip()]
3623
+ for field, value in (
3624
+ ("status_ref", status_ref),
3625
+ ("scenario_ref", scenario_ref),
3626
+ ("custom_dimensions", custom_dimensions),
3627
+ ("at", at),
3628
+ ):
3629
+ if value is not None:
3630
+ row[field] = value
3631
+ return row or None
3632
+
3633
+
3634
+ _CITES_OPTIONS = [
3635
+ click.option("--items", "item_refs", default=None,
3636
+ help="CSV of itemRefs this source backs."),
3637
+ click.option("--contexts", "context_refs", default=None,
3638
+ help="CSV of contextRefs. Omit to cover every context the statusRef allows."),
3639
+ click.option("--status", "status_ref", default=None, help="statusRef for the coverage row."),
3640
+ click.option("--scenario", "scenario_ref", default=None, help="scenarioRef for the coverage row."),
3641
+ click.option("--custom-dimensions", "custom_dimensions", default=None,
3642
+ help="'dim:member;dim:member' for the coverage row."),
3643
+ ]
3644
+
3645
+
3646
+ def _with_cites_options(fn):
3647
+ for option in reversed(_CITES_OPTIONS):
3648
+ fn = option(fn)
3649
+ return fn
3650
+
3651
+
3652
+ @defs.command("add-source")
3653
+ @click.argument("filename")
3654
+ @click.option("--id", "source_id", required=True, help="Stable sourceId, e.g. 'src_aapl_10k_fy25'.")
3655
+ @click.option(
3656
+ "--kind",
3657
+ default=None,
3658
+ help=(
3659
+ "What the source physically IS - closed set: filing, webpage, pdf, "
3660
+ "workbook, dataset, query, transcript, media, message, document, "
3661
+ "person, derived, other. What it is USED AS goes in --role."
3662
+ ),
3663
+ )
3664
+ @click.option(
3665
+ "--role",
3666
+ default=None,
3667
+ help=(
3668
+ "What the source is being used AS - free text: historical_actual, "
3669
+ "assumption, analyst_estimate, management_guidance, industry_benchmark, "
3670
+ "market_data, manual, ..."
3671
+ ),
3672
+ )
3673
+ @click.option(
3674
+ "--reach",
3675
+ default=None,
3676
+ help=(
3677
+ "Can a recipient open it? public | account | private | offline. "
3678
+ "'private' withholds the locator on share and export; 'offline' means "
3679
+ "no locator exists, which is a complete record, not a broken one."
3680
+ ),
3681
+ )
3682
+ @click.option("--title", default=None, help="Human-readable title - what renders when the locator is withheld.")
3683
+ @click.option("--locator", default=None, help="The one outward address (URL, path, DSN).")
3684
+ @click.option("--description", default=None, help="Longer note about the source.")
3685
+ @click.option(
3686
+ "--at",
3687
+ "at",
3688
+ default=None,
3689
+ help=(
3690
+ "Default position inside the source: text:HEADING[,SUFFIX] | page:47 | "
3691
+ "sheet:Name!A1:B9 | row:1042 | col:arr | t:00:14:32 | anchor:id."
3692
+ ),
3693
+ )
3694
+ @click.option("--effective-date", default=None, help="ISO date the source describes.")
3695
+ @click.option("--retrieved-at", default=None, help="ISO timestamp it was fetched.")
3696
+ @click.option("--issuer", default=None, help="Who published it.")
3697
+ @click.option("--ticker", "issuer_ticker", default=None, help="Issuer ticker symbol.")
3698
+ @click.option(
3699
+ "--identity",
3700
+ "identities",
3701
+ multiple=True,
3702
+ help="Declared identity as 'scheme:value' (e.g. 'sec.accession:0000320193-25-000079'). Repeatable.",
3703
+ )
3704
+ @_with_cites_options
3705
+ @click.option("--index", type=int, default=None, help="Position (omit to append at end).")
3706
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3707
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3708
+ @pass_ctx
3709
+ def add_source(
3710
+ ctx: Ctx,
3711
+ filename: str,
3712
+ source_id: str,
3713
+ kind: str | None,
3714
+ role: str | None,
3715
+ reach: str | None,
3716
+ title: str | None,
3717
+ locator: str | None,
3718
+ description: str | None,
3719
+ at: str | None,
3720
+ effective_date: str | None,
3721
+ retrieved_at: str | None,
3722
+ issuer: str | None,
3723
+ issuer_ticker: str | None,
3724
+ identities: tuple[str, ...],
3725
+ item_refs: str | None,
3726
+ context_refs: str | None,
3727
+ status_ref: str | None,
3728
+ scenario_ref: str | None,
3729
+ custom_dimensions: str | None,
3730
+ index: int | None,
3731
+ revision: str | None,
3732
+ workspace_slug: str | None,
3733
+ ) -> None:
3734
+ """Declare a <Source> - the one place an outward address may live."""
3735
+ slug = workspace_slug or ctx.require_workspace()
3736
+ defaults: dict[str, Any] = {"name": source_id}
3737
+ for field, value in (
3738
+ ("kind", kind),
3739
+ ("role", role),
3740
+ ("reach", reach),
3741
+ ("title", title),
3742
+ ("locator", locator),
3743
+ ("description", description),
3744
+ ("at", at),
3745
+ ("effective_date", effective_date),
3746
+ ("retrieved_at", retrieved_at),
3747
+ ("issuer", issuer),
3748
+ ("issuer_ticker", issuer_ticker),
3749
+ ):
3750
+ if value is not None:
3751
+ defaults[field] = value
3752
+
3753
+ parsed_identities: list[dict[str, str]] = []
3754
+ for raw in identities:
3755
+ scheme, sep, value = raw.partition(":")
3756
+ if not sep or not scheme.strip() or not value.strip():
3757
+ raise click.UsageError(
3758
+ f"--identity must be 'scheme:value', got {raw!r} "
3759
+ "(e.g. 'sec.accession:0000320193-25-000079')"
3760
+ )
3761
+ parsed_identities.append({"scheme": scheme.strip(), "value": value.strip()})
3762
+ if parsed_identities:
3763
+ defaults["identities"] = parsed_identities
3764
+
3765
+ row = _cites_row(item_refs, context_refs, status_ref, scenario_ref, custom_dimensions, at)
3766
+ if row and row.get("item_refs"):
3767
+ defaults["cites"] = [row]
3768
+
3769
+ op: dict[str, Any] = {"kind": "add_source", "defaults": defaults}
3770
+ if index is not None:
3771
+ op["index"] = index
3772
+ _apply(ctx, slug, filename, [op], revision=revision)
3773
+
3774
+
3775
+ @defs.command("update-source")
3776
+ @click.argument("filename")
3777
+ @click.option("--id", "source_id", required=True, help="sourceId to update.")
3778
+ @click.option("--kind", default=None, help="New @kind (closed set).")
3779
+ @click.option("--role", default=None, help="New @role (free text).")
3780
+ @click.option("--reach", default=None, help="public | account | private | offline.")
3781
+ @click.option("--title", default=None, help="New title. Pass '' to clear.")
3782
+ @click.option("--locator", default=None, help="New locator. Pass '' to clear.")
3783
+ @click.option("--description", default=None, help="New description. Pass '' to clear.")
3784
+ @click.option("--at", "at", default=None, help="New default position.")
3785
+ @click.option("--effective-date", default=None, help="New effective date.")
3786
+ @click.option("--retrieved-at", default=None, help="New retrieval timestamp.")
3787
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3788
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3789
+ @pass_ctx
3790
+ def update_source(
3791
+ ctx: Ctx,
3792
+ filename: str,
3793
+ source_id: str,
3794
+ kind: str | None,
3795
+ role: str | None,
3796
+ reach: str | None,
3797
+ title: str | None,
3798
+ locator: str | None,
3799
+ description: str | None,
3800
+ at: str | None,
3801
+ effective_date: str | None,
3802
+ retrieved_at: str | None,
3803
+ revision: str | None,
3804
+ workspace_slug: str | None,
3805
+ ) -> None:
3806
+ """Change fields on a <Source>. Omitted flags are left alone; '' clears."""
3807
+ slug = workspace_slug or ctx.require_workspace()
3808
+ defaults: dict[str, Any] = {}
3809
+ for field, value in (
3810
+ ("kind", kind),
3811
+ ("role", role),
3812
+ ("reach", reach),
3813
+ ("title", title),
3814
+ ("locator", locator),
3815
+ ("description", description),
3816
+ ("at", at),
3817
+ ("effective_date", effective_date),
3818
+ ("retrieved_at", retrieved_at),
3819
+ ):
3820
+ if value is not None:
3821
+ defaults[field] = value
3822
+ if not defaults:
3823
+ raise click.UsageError("Nothing to update - pass at least one field.")
3824
+ op = {"kind": "update_source", "sourceRef": source_id, "defaults": defaults}
3825
+ _apply(ctx, slug, filename, [op], revision=revision)
3826
+
3827
+
3828
+ @defs.command("delete-source")
3829
+ @click.argument("filename")
3830
+ @click.option("--id", "source_id", required=True, help="sourceId to delete.")
3831
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3832
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3833
+ @pass_ctx
3834
+ def delete_source(
3835
+ ctx: Ctx,
3836
+ filename: str,
3837
+ source_id: str,
3838
+ revision: str | None,
3839
+ workspace_slug: str | None,
3840
+ ) -> None:
3841
+ """Remove a <Source>. Refused while any <Evidence> still cites it."""
3842
+ slug = workspace_slug or ctx.require_workspace()
3843
+ op = {"kind": "delete_source", "sourceRef": source_id}
3844
+ _apply(ctx, slug, filename, [op], revision=revision)
3845
+
3846
+
3847
+ @defs.command("set-source-cites")
3848
+ @click.argument("filename")
3849
+ @click.option("--id", "source_id", required=True, help="sourceId whose coverage to replace.")
3850
+ @_with_cites_options
3851
+ @click.option("--at", "at", default=None, help="Position override for this coverage row.")
3852
+ @click.option(
3853
+ "--clear",
3854
+ is_flag=True,
3855
+ default=False,
3856
+ help="Remove every coverage row instead of setting one.",
3857
+ )
3858
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
3859
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
3860
+ @pass_ctx
3861
+ def set_source_cites(
3862
+ ctx: Ctx,
3863
+ filename: str,
3864
+ source_id: str,
3865
+ item_refs: str | None,
3866
+ context_refs: str | None,
3867
+ status_ref: str | None,
3868
+ scenario_ref: str | None,
3869
+ custom_dimensions: str | None,
3870
+ at: str | None,
3871
+ clear: bool,
3872
+ revision: str | None,
3873
+ workspace_slug: str | None,
3874
+ ) -> None:
3875
+ """Replace which cells a <Source> backs.
3876
+
3877
+ Coverage is the field revised most often - after values land - so it has
3878
+ its own command: doing it through `update-source` would risk blanking a
3879
+ title by leaving it out of a partial payload.
3880
+ """
3881
+ slug = workspace_slug or ctx.require_workspace()
3882
+ if clear:
3883
+ rows: list[dict[str, Any]] = []
3884
+ else:
3885
+ row = _cites_row(item_refs, context_refs, status_ref, scenario_ref, custom_dimensions, at)
3886
+ if not row or not row.get("item_refs"):
3887
+ raise click.UsageError("Pass --items (or --clear to remove all coverage).")
3888
+ rows = [row]
3889
+ op = {"kind": "set_source_cites", "sourceRef": source_id, "cites": rows}
3890
+ _apply(ctx, slug, filename, [op], revision=revision)