deepcell-cli 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,718 @@
1
+ """Query commands: query, cell-meta, relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import click
8
+
9
+ from deepcell_cli.context import Ctx, pass_ctx
10
+ from deepcell_cli.output import echo_warning, output, print_plain
11
+
12
+ #: ``ITEM[CONTEXT]`` — the ref syntax formulas are written in, and therefore the
13
+ #: first thing an agent types at the query prompt after reading a CalcDef. It
14
+ #: matches no ``itemId``, so ``item_values`` answered ``[]`` with
15
+ #: ``success: true`` — a green empty result that reads as "this row has no
16
+ #: data" rather than "you passed the wrong shape" (#1197 item 1).
17
+ #:
18
+ #: Deliberately anchored and bracket-free on both sides: only a token that is
19
+ #: *entirely* ``name[name]`` is rescued. An id that merely contains a bracket is
20
+ #: passed through so the server answers with its own did-you-mean list — a
21
+ #: fail-loud guard that over-triggers is worse than the silence it replaces.
22
+ _FORMULA_REF_RE = re.compile(r"^([^\[\]\s]+)\[([^\[\]]+)\]$")
23
+
24
+
25
+ def _reject_formula_ref(filename: str, ref: str | None) -> None:
26
+ """Raise if *ref* is written as a formula ref rather than a query argument."""
27
+ match = _FORMULA_REF_RE.match((ref or "").strip())
28
+ if not match:
29
+ return
30
+ item, context = match.group(1), match.group(2).strip()
31
+ raise click.UsageError(
32
+ f"unknown item '{ref}' — `{ref}` is formula ref syntax, not a query "
33
+ "argument. `query` takes ITEM and CONTEXT as separate positional "
34
+ "arguments:\n"
35
+ f" One cell: deepcell query {filename} {item} {context}\n"
36
+ f" Every context: deepcell query {filename} {item}"
37
+ )
38
+
39
+
40
+ #: Prefix marking a displayed number that is NOT the stored number.
41
+ _ROUNDED = "~"
42
+
43
+
44
+ def _format_value(value: object, data_type: str | None) -> str:
45
+ """Format a value for display based on data type.
46
+
47
+ Rounded output is marked with a leading ``~``. Monetary values print with
48
+ no decimals, so a stored 13.44 rendered as a bare ``13`` — for an audit
49
+ confirming a claimed figure that is a 3.3% discrepancy the reader cannot
50
+ see, and the only way to notice was to re-run with ``-f json``. The marker
51
+ is cheap, survives inside table cells, and says exactly one thing: ask for
52
+ JSON if you need the exact value. Values that round-trip exactly are
53
+ unmarked, so ``~`` never appears on a number that is what it says.
54
+ """
55
+ if not isinstance(value, (int, float)):
56
+ return str(value)
57
+ if isinstance(value, bool): # bool is an int; formatting it as one is wrong
58
+ return str(value)
59
+
60
+ if data_type == "percentage":
61
+ shown, exact = f"{value * 100:.1f}%", value * 100
62
+ elif data_type == "monetary":
63
+ shown, exact = f"{value:,.0f}", value
64
+ elif data_type == "ratio":
65
+ shown, exact = f"{value:.1f}x", value
66
+ elif data_type in ("quantity", "count"):
67
+ shown, exact = f"{value:.1f}", value
68
+ else:
69
+ return str(value)
70
+
71
+ decimals = 0 if data_type == "monetary" else 1
72
+ # Compare against the same rounding the format applied, rather than
73
+ # re-parsing the formatted string (thousands separators, the % and x
74
+ # suffixes).
75
+ if abs(exact - round(exact, decimals)) > 1e-9:
76
+ return _ROUNDED + shown
77
+ return shown
78
+
79
+
80
+ def _get_xml(ctx: Ctx, filename: str) -> str:
81
+ """Compatibility loader for legacy reasoning commands (see issue #1118)."""
82
+ slug = ctx.require_workspace()
83
+ file_data = ctx.client.get(f"/workspaces/{slug}/files/{filename}")
84
+ xml = file_data.get("content", "") if isinstance(file_data, dict) else ""
85
+ if not xml:
86
+ raise click.ClickException(f"File '{filename}' is empty or not found.")
87
+ return xml
88
+
89
+
90
+ def _parse_cells(spec: str) -> list[tuple[str, str, str | None]]:
91
+ """Parse ``ITEM@CONTEXT[@STATUS],...`` into coordinate triples.
92
+
93
+ ``@`` rather than a second delimiter inside a comma list because item and
94
+ context ids are themselves free-form and both may contain ``_`` and ``-``;
95
+ ``@`` appears in neither (`ref:selector`). A token with no ``@`` is a
96
+ caller who meant the one-cell form, and is told so rather than being
97
+ read as an item with an empty context.
98
+ """
99
+ out: list[tuple[str, str, str | None]] = []
100
+ for raw in spec.split(","):
101
+ token = raw.strip()
102
+ if not token:
103
+ continue
104
+ parts = [p.strip() for p in token.split("@")]
105
+ if len(parts) < 2 or not parts[0] or not parts[1]:
106
+ raise click.UsageError(
107
+ f"--cells entry {token!r} is not ITEM@CONTEXT. Each entry "
108
+ "names one cell:\n"
109
+ " --cells Revenue@FY2025E,EBIT@FY2025E\n"
110
+ " --cells Revenue@FY2025E@actual (pin one cell's status)\n"
111
+ "For every period of ONE item, use the positional form: "
112
+ "`query FILE Revenue`."
113
+ )
114
+ if len(parts) > 3:
115
+ raise click.UsageError(
116
+ f"--cells entry {token!r} has too many '@' segments — "
117
+ "ITEM@CONTEXT@STATUS is the most an entry carries."
118
+ )
119
+ out.append((parts[0], parts[1], parts[2] if len(parts) == 3 else None))
120
+ if not out:
121
+ raise click.UsageError("--cells was empty — list at least one ITEM@CONTEXT.")
122
+ return out
123
+
124
+
125
+ def _query_cells(
126
+ ctx: Ctx,
127
+ slug: str,
128
+ filename: str,
129
+ spec: str,
130
+ *,
131
+ status_ref: str | None,
132
+ scenario_ref: str | None,
133
+ custom_dimensions: str | None,
134
+ ) -> None:
135
+ """Read every listed cell in one invocation.
136
+
137
+ Still one POST per cell — the server has no multi-cell read and inventing
138
+ one would be a second implementation of resolution to keep in step. What
139
+ this collapses is *rounds*, which is what the cost is: the eval corpus has
140
+ a run that verified twenty-one headline cells with twenty-one separate
141
+ commands, each one a full context round-trip for a ~150-byte answer.
142
+
143
+ A cell that fails does not abort the rest. Aborting would hand back one
144
+ error and no values, and the caller would pay another round for the cells
145
+ that were fine — so every cell is reported and the exit code is nonzero
146
+ if any of them failed.
147
+ """
148
+ from deepcell_cli.errors import APIError
149
+
150
+ coords = _parse_cells(spec)
151
+ for item, context, _ in coords:
152
+ _reject_formula_ref(filename, item)
153
+ _reject_formula_ref(filename, context)
154
+
155
+ rows: list[dict] = []
156
+ failed = 0
157
+ for item, context, per_cell_status in coords:
158
+ body: dict = {
159
+ "workspace_slug": slug,
160
+ "filename": filename,
161
+ "query_type": "value",
162
+ "item_ref": item,
163
+ "context_ref": context,
164
+ }
165
+ effective_status = per_cell_status or status_ref
166
+ if effective_status:
167
+ body["status_ref"] = effective_status
168
+ if scenario_ref:
169
+ body["scenario_ref"] = scenario_ref
170
+ if custom_dimensions:
171
+ body["custom_dimensions"] = custom_dimensions
172
+
173
+ label = f"{item}@{context}" + (f"@{per_cell_status}" if per_cell_status else "")
174
+ try:
175
+ data = ctx.client.post("/query", json=body)
176
+ except APIError as exc:
177
+ failed += 1
178
+ echo_warning(f"{label}: {exc}")
179
+ rows.append({"cell": label, "item_ref": item, "context_ref": context,
180
+ "error": str(exc)})
181
+ continue
182
+
183
+ result = data.get("result") if isinstance(data, dict) else None
184
+ if isinstance(result, dict):
185
+ for w in result.get("warnings") or []:
186
+ echo_warning(f"{label}: {w}")
187
+ if result.get("error_kind"):
188
+ echo_warning(f"{label}: " + _format_cell_error_warning(result))
189
+ rows.append({"cell": label, "item_ref": item, "context_ref": context,
190
+ "result": result})
191
+
192
+ if ctx.fmt == "plain":
193
+ lines = []
194
+ for r in rows:
195
+ if "error" in r:
196
+ lines.append(f"{r['cell']}: (error)")
197
+ continue
198
+ res = r.get("result")
199
+ if isinstance(res, dict) and res.get("value") is not None:
200
+ lines.append(
201
+ f"{r['cell']}: "
202
+ + _format_value(res.get("value"), res.get("data_type"))
203
+ )
204
+ else:
205
+ lines.append(f"{r['cell']}: (empty)")
206
+ print_plain("\n".join(lines))
207
+ else:
208
+ output(
209
+ {"query_type": "cells", "results": rows, "success": failed == 0},
210
+ ctx.fmt,
211
+ )
212
+
213
+ if failed:
214
+ raise click.exceptions.Exit(1)
215
+
216
+
217
+ class _QueryCommand(click.Command):
218
+ """Lets `query` report extra positionals itself.
219
+
220
+ Click's own "Got unexpected extra arguments" never mentions that slot 3 is
221
+ STATUS_REF, so a caller listing periods positionally has already had one
222
+ silently read as a status. Allowing extra args here (they are rejected in the
223
+ callback) keeps that error ours *without* declaring a variadic argument —
224
+ which would publish a fake positional into the surface manifest and read, to
225
+ an agent, as license to pass any number of periods.
226
+ """
227
+
228
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
229
+ ctx.allow_extra_args = True
230
+ return super().parse_args(ctx, args)
231
+
232
+
233
+ @click.command(cls=_QueryCommand)
234
+ @click.argument("filename")
235
+ @click.argument("item_ref", required=False)
236
+ @click.argument("context_ref", required=False)
237
+ @click.argument("status_ref", required=False)
238
+ @click.option(
239
+ "--status", "status_opt", default=None,
240
+ help=(
241
+ "Status dimension — the same thing as the third positional. `edit`, "
242
+ "`defs add-calc` and `cell-meta` all spell it this way; accepted here "
243
+ "so the four agree."
244
+ ),
245
+ )
246
+ @click.option("--scenario", "scenario_ref", default=None, help="Scenario dimension (omit for default scenario).")
247
+ @click.option("--custom-dimensions", "custom_dimensions", default=None,
248
+ help="Custom dimensions as 'dim:member;dim:member', e.g. 'geography:na'.")
249
+ @click.option(
250
+ "--cells",
251
+ "cells_arg",
252
+ default=None,
253
+ help=(
254
+ "Read several cells that do NOT share a row, in one call: "
255
+ "'ITEM@CONTEXT,ITEM@CONTEXT,...' (add a third '@STATUS' segment per "
256
+ "cell to pin its status). This is the check-my-outputs form — a "
257
+ "comma-separated CONTEXT_REF only walks one item."
258
+ ),
259
+ )
260
+ @click.option("--sheet", "sheet_id", help="Render a full sheet as markdown table.")
261
+ @click.option("--block", "block_id", help="Render a specific block within a sheet.")
262
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
263
+ @pass_ctx
264
+ def query(
265
+ ctx: Ctx,
266
+ filename: str,
267
+ item_ref: str | None,
268
+ context_ref: str | None,
269
+ status_ref: str | None,
270
+ status_opt: str | None,
271
+ scenario_ref: str | None,
272
+ custom_dimensions: str | None,
273
+ cells_arg: str | None,
274
+ sheet_id: str | None,
275
+ block_id: str | None,
276
+ workspace_slug: str | None,
277
+ ) -> None:
278
+ """Query a .deepcell file.
279
+
280
+ \b
281
+ Values are addressed by up to five dimensions:
282
+ ITEM_REF what to query (e.g. Revenue, COGS)
283
+ CONTEXT_REF time period (e.g. FY2025E, Q1_2024)
284
+ STATUS_REF a statusRef the file declares — `describe` lists them;
285
+ actual | projected are the common pair (optional, default: all)
286
+ --status the same status dimension, as a flag
287
+ --scenario scenario dimension (optional, default: default scenario)
288
+ --custom-dimensions custom dimensions (optional, default: base cell)
289
+
290
+ \b
291
+ All values: deepcell query model.deepcell Revenue
292
+ Single value: deepcell query model.deepcell Revenue FY2025E projected
293
+ Some periods: deepcell query model.deepcell Revenue FY2025E,FY2026E
294
+ Scenario cell: deepcell query model.deepcell Revenue FY2025E --scenario bull
295
+ Scenario row: deepcell query model.deepcell Revenue --scenario bull
296
+ Custom dim: deepcell query model.deepcell Revenue FY2025E --custom-dimensions geography:na
297
+ Full sheet: deepcell query model.deepcell --sheet income_statement
298
+ Sheet in world: deepcell query model.deepcell --sheet income_statement --scenario bull
299
+ Many cells: deepcell query model.deepcell --cells EV@FY2026E,WACC@FY2026E,EV_Check@FY2026E
300
+
301
+ \b
302
+ Reading back what you built is a read, not a conversation: `--sheet` for a
303
+ whole block and `--cells` for scattered outputs each answer in ONE call.
304
+ Checking a dozen headline cells one command at a time costs a dozen
305
+ round-trips and tells you nothing extra.
306
+
307
+ \b
308
+ ITEM and CONTEXT are SEPARATE arguments. `Revenue[FY2025E]` is formula
309
+ syntax and is rejected here — it is not an item id.
310
+
311
+ Omitting --scenario returns the default-scenario value; omitting
312
+ --custom-dimensions returns the base (no-dimension) cell. On --sheet,
313
+ --scenario names the world the WHOLE sheet is read in and answers with
314
+ the numbers the grid shows at that scenario; --custom-dimensions has no
315
+ meaning there (a sheet renders every member) and is rejected. For
316
+ scenario analysis details see `deepcell guide revise/scenarios`. To view
317
+ in the browser instead, see `deepcell guide present/deliver`.
318
+ """
319
+ slug = workspace_slug or ctx.require_workspace()
320
+ # `query` took status positionally while `edit`, `defs add-calc` and
321
+ # `cell-meta` took `--status`, so the flag fell through to Click's generic
322
+ # "No such option" — an answer that says the dimension is unavailable
323
+ # rather than that it is spelled differently here.
324
+ if status_opt is not None:
325
+ if status_ref is not None and status_ref != status_opt:
326
+ raise click.UsageError(
327
+ f"--status {status_opt!r} contradicts the third positional "
328
+ f"argument {status_ref!r}. They are the same dimension; pass "
329
+ f"one."
330
+ )
331
+ status_ref = status_opt
332
+ extra_args = tuple(click.get_current_context().args)
333
+ if extra_args:
334
+ # Periods listed positionally: slot 3 is STATUS_REF, so the first extra
335
+ # was already misread as a status before we got here. Name both working
336
+ # forms rather than leaving the caller to re-derive them.
337
+ listed = [a for a in (context_ref, status_ref, *extra_args) if a]
338
+ raise click.UsageError(
339
+ "Too many positional arguments — the third slot is STATUS_REF, not "
340
+ "another period.\n"
341
+ f" Every period: deepcell query {filename} {item_ref}\n"
342
+ f" Some periods: deepcell query {filename} {item_ref} "
343
+ + ",".join(listed)
344
+ + "\n"
345
+ f" One cell: deepcell query {filename} {item_ref} {listed[0]} "
346
+ "[STATUS_REF]"
347
+ )
348
+ _reject_formula_ref(filename, item_ref)
349
+ _reject_formula_ref(filename, context_ref)
350
+ if block_id and not sheet_id:
351
+ raise click.UsageError("--block requires --sheet.")
352
+ if sheet_id and (item_ref or context_ref or status_ref):
353
+ raise click.UsageError(
354
+ "Use either positional item/context/status or --sheet/--block, "
355
+ "not both."
356
+ )
357
+ if cells_arg is not None:
358
+ # --status / --scenario / --custom-dimensions stay legal here: they
359
+ # narrow every listed cell the same way, which is what a read-back of
360
+ # one scenario's headline numbers wants. Only the coordinates conflict.
361
+ if item_ref or context_ref or sheet_id or block_id:
362
+ raise click.UsageError(
363
+ "--cells carries its own coordinates. Use either "
364
+ "ITEM_REF/CONTEXT_REF positionals, or --sheet/--block, or "
365
+ "--cells — not two of them."
366
+ )
367
+ _query_cells(
368
+ ctx,
369
+ slug,
370
+ filename,
371
+ cells_arg,
372
+ status_ref=status_ref,
373
+ scenario_ref=scenario_ref,
374
+ custom_dimensions=custom_dimensions,
375
+ )
376
+ return
377
+ # --scenario is the exception: it names the WORLD the sheet is read in,
378
+ # not a cell coordinate, so it composes with --sheet. --custom-dimensions
379
+ # does not — a sheet renders every member, so there is no slice to name.
380
+ if sheet_id and custom_dimensions:
381
+ raise click.UsageError(
382
+ "--custom-dimensions does not apply to --sheet: a sheet renders "
383
+ "every dimension member. Drop it, or query a cell with "
384
+ "ITEM_REF CONTEXT_REF."
385
+ )
386
+ # --scenario / --custom-dimensions name a cell coordinate, so they need an
387
+ # ITEM_REF — but NOT a CONTEXT_REF (#1197 item 4). Reading one scenario's
388
+ # whole trajectory used to cost one call per period. A --sheet read has
389
+ # already been settled above: there the scenario names the world, not a
390
+ # cell, and no ITEM_REF exists to require.
391
+ if (scenario_ref or custom_dimensions) and not item_ref and not sheet_id:
392
+ raise click.UsageError(
393
+ "--scenario / --custom-dimensions require an ITEM_REF (add a "
394
+ "CONTEXT_REF for a single cell, omit it for every context)."
395
+ )
396
+
397
+ # A comma-separated CONTEXT_REF reads a subset of periods. It has to be a
398
+ # list inside one token rather than several positionals, because slot 3 is
399
+ # STATUS_REF and `projected` must stay distinguishable from a period id.
400
+ contexts = [c.strip() for c in (context_ref or "").split(",") if c.strip()]
401
+ if item_ref and len(contexts) > 1:
402
+ rows = []
403
+ for one in contexts:
404
+ body = {
405
+ "workspace_slug": slug,
406
+ "filename": filename,
407
+ "query_type": "value",
408
+ "item_ref": item_ref,
409
+ "context_ref": one,
410
+ }
411
+ if status_ref:
412
+ body["status_ref"] = status_ref
413
+ if scenario_ref:
414
+ body["scenario_ref"] = scenario_ref
415
+ if custom_dimensions:
416
+ body["custom_dimensions"] = custom_dimensions
417
+ data = ctx.client.post("/query", json=body)
418
+ result = data.get("result") if isinstance(data, dict) else None
419
+ if isinstance(result, dict):
420
+ for w in result.get("warnings") or []:
421
+ echo_warning(f"{one}: {w}")
422
+ rows.append({"context_ref": one, "result": result})
423
+ if ctx.fmt == "plain":
424
+ print_plain("\n".join(
425
+ f"{r['context_ref']}: " + (
426
+ _format_value(
427
+ (r["result"] or {}).get("value", ""),
428
+ (r["result"] or {}).get("data_type"),
429
+ )
430
+ if isinstance(r["result"], dict)
431
+ and r["result"].get("value") is not None
432
+ else "(empty)"
433
+ )
434
+ for r in rows
435
+ ))
436
+ else:
437
+ output({"query_type": "value", "results": rows, "success": True}, ctx.fmt)
438
+ return
439
+ if contexts:
440
+ context_ref = contexts[0]
441
+
442
+ if sheet_id:
443
+ # Sheet query
444
+ body: dict = {
445
+ "workspace_slug": slug,
446
+ "filename": filename,
447
+ "query_type": "sheet",
448
+ "sheet_id": sheet_id,
449
+ }
450
+ if block_id:
451
+ body["block_id"] = block_id
452
+ # The world the sheet is read in. Same flag, same meaning as on a cell
453
+ # read; the server names it to the builder rather than baking it, so
454
+ # this answers with the numbers the grid shows at that scenario.
455
+ if scenario_ref:
456
+ body["scenario_ref"] = scenario_ref
457
+ elif item_ref and context_ref:
458
+ # Value query
459
+ body = {
460
+ "workspace_slug": slug,
461
+ "filename": filename,
462
+ "query_type": "value",
463
+ "item_ref": item_ref,
464
+ "context_ref": context_ref,
465
+ }
466
+ if status_ref:
467
+ body["status_ref"] = status_ref
468
+ if scenario_ref:
469
+ body["scenario_ref"] = scenario_ref
470
+ if custom_dimensions:
471
+ body["custom_dimensions"] = custom_dimensions
472
+ elif item_ref:
473
+ # Item values query (all contexts)
474
+ body = {
475
+ "workspace_slug": slug,
476
+ "filename": filename,
477
+ "query_type": "item_values",
478
+ "item_ref": item_ref,
479
+ }
480
+ if status_ref:
481
+ body["status_ref"] = status_ref
482
+ # The scenario / custom-dimension axes narrow the row the same way they
483
+ # narrow a single cell (#1197 item 4).
484
+ if scenario_ref:
485
+ body["scenario_ref"] = scenario_ref
486
+ if custom_dimensions:
487
+ body["custom_dimensions"] = custom_dimensions
488
+ else:
489
+ raise click.UsageError(
490
+ "Provide ITEM_REF for item query, ITEM_REF CONTEXT_REF for value query, or --sheet for sheet query."
491
+ )
492
+
493
+ data = ctx.client.post("/query", json=body)
494
+
495
+ # Server-side read warnings (e.g. a value resolved from an orphan cell
496
+ # tagged with an undefined scenario id, #1149) go to stderr so the value
497
+ # on stdout never reads as unconditionally clean.
498
+ if isinstance(data, dict) and isinstance(data.get("result"), dict):
499
+ for w in data["result"].get("warnings") or []:
500
+ echo_warning(str(w))
501
+
502
+ # #1272 step (iii). `query` is the surface where this matters most:
503
+ # the answer is a bare number, so a formula that collapsed to 0
504
+ # through #REF! or #DIV/0 prints as `0` and reads as a real zero.
505
+ # Emitted here, before every formatting branch below returns early,
506
+ # so no output shape can swallow it.
507
+ if data["result"].get("error_kind"):
508
+ echo_warning(_format_cell_error_warning(data["result"]))
509
+
510
+ # For sheet queries the result is usually markdown — print as plain
511
+ if sheet_id and isinstance(data, dict):
512
+ result = data.get("result", data)
513
+ if isinstance(result, str):
514
+ print_plain(result)
515
+ return
516
+
517
+ # For item_values queries, truncate and format the result list
518
+ if body.get("query_type") == "item_values" and isinstance(data, dict):
519
+ result = data.get("result", [])
520
+ if isinstance(result, list):
521
+ max_display = 20
522
+ total = len(result)
523
+ truncated = result[:max_display]
524
+ if ctx.fmt == "plain":
525
+ # Tag the status when present — the same context can carry a
526
+ # value per status, and untagged duplicate lines are unreadable.
527
+ lines = [
528
+ (
529
+ f"{r.get('context_ref', '')}"
530
+ + (f" [{r['status_ref']}]" if r.get("status_ref") else "")
531
+ + f": {_format_value(r.get('value', ''), r.get('data_type'))}"
532
+ )
533
+ for r in truncated
534
+ ]
535
+ if total > max_display:
536
+ lines.append(f"... and {total - max_display} more")
537
+ print_plain("\n".join(lines))
538
+ else:
539
+ if total > max_display:
540
+ data["result"] = truncated
541
+ data["truncated"] = total - max_display
542
+ output(data, ctx.fmt)
543
+ return
544
+
545
+ # Value query that resolved to no single value: the cell is empty, or it is
546
+ # populated under multiple statuses (no single status resolves). Surface the
547
+ # per-status breakdown for plain output rather than a raw JSON blob.
548
+ if (
549
+ body.get("query_type") == "value"
550
+ and ctx.fmt == "plain"
551
+ and isinstance(data, dict)
552
+ ):
553
+ result = data.get("result")
554
+ if isinstance(result, dict) and result.get("value") is None:
555
+ vbs = result.get("values_by_status") or []
556
+ if vbs:
557
+ dtype = result.get("data_type")
558
+ lines = (
559
+ ["(value differs by status — specify a status)"]
560
+ if len(vbs) > 1
561
+ else []
562
+ )
563
+ lines += [
564
+ f"{e.get('status_ref', '')}: {_format_value(e.get('value', ''), dtype)}"
565
+ for e in vbs
566
+ ]
567
+ print_plain("\n".join(lines))
568
+ else:
569
+ print_plain("(empty)")
570
+ return
571
+
572
+ # Format percentage values for plain output on single-value queries
573
+ if ctx.fmt == "plain" and isinstance(data, dict):
574
+ result = data.get("result", data)
575
+ if isinstance(result, dict) and result.get("data_type") == "percentage":
576
+ val = result.get("value")
577
+ if isinstance(val, (int, float)):
578
+ print_plain(_format_value(val, "percentage"))
579
+ return
580
+
581
+ output(data, ctx.fmt)
582
+
583
+
584
+ @click.command()
585
+ @click.argument("filename")
586
+ @click.argument("item_ref_pos", metavar="[ITEM_REF]", required=False)
587
+ @click.argument("context_ref_pos", metavar="[CONTEXT_REF]", required=False)
588
+ @click.option("--item", "item_opt", default=None,
589
+ help="Item ref (alternative to the ITEM_REF positional).")
590
+ @click.option("--context", "context_opt", default=None,
591
+ help="Context ref (alternative to the CONTEXT_REF positional).")
592
+ @click.option(
593
+ "--status",
594
+ "status_ref",
595
+ default=None,
596
+ help=(
597
+ "Status reference (e.g. 'actual'). A CONSTRAINT, not a hint: the read "
598
+ "may answer from that bucket or the untagged one, never from a third. "
599
+ "Omit to let the document decide which status answers."
600
+ ),
601
+ )
602
+ @click.option("--scenario", "scenario_ref", default=None, help="Scenario dimension (omit for the base cell).")
603
+ @click.option("--custom-dimensions", "custom_dimensions", default=None,
604
+ help="Custom dimensions as 'dim:member;dim:member', e.g. 'geography:na'.")
605
+ @pass_ctx
606
+ def cell_meta(
607
+ ctx: Ctx,
608
+ filename: str,
609
+ item_ref_pos: str | None,
610
+ context_ref_pos: str | None,
611
+ item_opt: str | None,
612
+ context_opt: str | None,
613
+ status_ref: str | None,
614
+ scenario_ref: str | None,
615
+ custom_dimensions: str | None,
616
+ ) -> None:
617
+ """Show metadata for a single cell (formula, dependencies, data source).
618
+
619
+ The coordinate can be positional (`cell-meta FILE ITEM CONTEXT`) or
620
+ flags (`--item` / `--context`) — most other commands take flags, and the
621
+ required-positional-only form was a measurable trap (eval U5).
622
+
623
+ Returns the cell's formula (if calculated), dependency graph, and
624
+ <Source> provenance (if the value was imported from an external source).
625
+ Run `deepcell guide generate/values` for more on data provenance.
626
+
627
+ Also returns which status answered: `status_ref` is the bucket the read
628
+ resolved to, and `status_alternatives` lists EVERY bucket that cell holds,
629
+ including the one that answered and including the untagged bucket as null.
630
+ More than one entry means the cell carries more than one number and you are
631
+ seeing one of them — see `deepcell guide verify/query-back`.
632
+ """
633
+ if item_ref_pos and item_opt:
634
+ raise click.UsageError("Pass ITEM_REF positionally or via --item, not both.")
635
+ if context_ref_pos and context_opt:
636
+ raise click.UsageError(
637
+ "Pass CONTEXT_REF positionally or via --context, not both."
638
+ )
639
+ item_ref = item_ref_pos or item_opt
640
+ context_ref = context_ref_pos or context_opt
641
+ if not item_ref or not context_ref:
642
+ raise click.UsageError(
643
+ "A complete cell coordinate is required: ITEM_REF and CONTEXT_REF "
644
+ "(positional, or --item/--context)."
645
+ )
646
+ slug = ctx.require_workspace()
647
+ body: dict = {
648
+ "workspace_slug": slug,
649
+ "source_filename": filename,
650
+ "item_ref": item_ref,
651
+ "context_ref": context_ref,
652
+ }
653
+ if status_ref:
654
+ body["status_ref"] = status_ref
655
+ if scenario_ref:
656
+ body["scenario_ref"] = scenario_ref
657
+ if custom_dimensions:
658
+ body["custom_dimensions"] = custom_dimensions
659
+
660
+ data = ctx.client.post("/cell-meta", json=body)
661
+ output(data, ctx.fmt)
662
+
663
+ # #1272 step (iii) — a typed formula error is IN the payload above, but a
664
+ # `#REF!` inside a one-line JSON blob is exactly the thing that gets
665
+ # skimmed past, and a cell that collapsed to 0 through a broken reference
666
+ # reads as a cell nobody filled in. On stderr, so stdout stays the machine
667
+ # contract. A warning and not an exit code, matching `describe`: the
668
+ # document still opens and renders.
669
+ if isinstance(data, dict) and data.get("error_kind"):
670
+ echo_warning(_format_cell_error_warning(data))
671
+
672
+
673
+ def _format_cell_error_warning(data: dict) -> str:
674
+ """Human line for a cell's typed formula error (#1272 step iii).
675
+
676
+ Names the undefined ids behind a `#REF!` when the server reported them:
677
+ the kind alone tells the reader something is broken, `missing_refs` tells
678
+ them *which* reference, which is the difference between a fix and a hunt.
679
+ """
680
+ kind = data.get("error_kind")
681
+ missing = [r for r in (data.get("missing_refs") or []) if r]
682
+ lines = [
683
+ f"this cell did not compute — {kind}. Its value is not a number you "
684
+ "can rely on."
685
+ ]
686
+ if missing:
687
+ names = ", ".join(missing)
688
+ plural = "" if len(missing) == 1 else "s"
689
+ lines.append(
690
+ f" Undefined item reference{plural}: {names}. Define "
691
+ "the item, or fix the"
692
+ )
693
+ lines.append(" formula to name one that exists.")
694
+ return "\n".join(lines)
695
+
696
+
697
+ @click.command()
698
+ @click.argument("filename")
699
+ @click.option(
700
+ "--type",
701
+ "graph_type",
702
+ default="unified",
703
+ type=click.Choice(["items", "dependencies", "blocks", "business", "unified"]),
704
+ help="Graph type. 'business' is the Item-centric Relationships workspace.",
705
+ )
706
+ @pass_ctx
707
+ def relationships(ctx: Ctx, filename: str, graph_type: str) -> None:
708
+ """Show the relationship graph of a .deepcell file."""
709
+ slug = ctx.require_workspace()
710
+ data = ctx.client.post(
711
+ "/relationships",
712
+ json={
713
+ "workspace_slug": slug,
714
+ "source_filename": filename,
715
+ "graph_type": graph_type,
716
+ },
717
+ )
718
+ output(data, ctx.fmt)