deepcell-cli 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -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)
|