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,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)
|