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,257 @@
|
|
|
1
|
+
"""``deepcell doctor`` — what this machine's DeepCell setup currently is.
|
|
2
|
+
|
|
3
|
+
An agent that has just installed the CLI needs four facts before it can act:
|
|
4
|
+
the CLI runs, the server answers, who it is signed in as (if anyone), and which
|
|
5
|
+
workspace its next command will land in. Those used to be scattered across
|
|
6
|
+
``--version``, ``whoami``, ``workspace list`` and the Status block appended to
|
|
7
|
+
``--help`` — four calls, three of which fail in exactly the situation you are
|
|
8
|
+
trying to diagnose.
|
|
9
|
+
|
|
10
|
+
Two rules shape this command:
|
|
11
|
+
|
|
12
|
+
* **It never changes anything.** In particular it does not mint an anonymous
|
|
13
|
+
session, which every other authenticated command does on first use. A
|
|
14
|
+
diagnostic that creates the identity it is reporting on cannot tell you
|
|
15
|
+
whether you had one.
|
|
16
|
+
* **It always exits 0.** "Not signed in" is an answer, not a failure. Reserving
|
|
17
|
+
a non-zero exit for "doctor itself could not run" keeps it usable as the
|
|
18
|
+
first probe in a script that branches on the result.
|
|
19
|
+
|
|
20
|
+
``--format json`` is the form to parse: ``next_command`` says what to run, so
|
|
21
|
+
an agent can act on the report without reading English. ``problem_kind`` says
|
|
22
|
+
*why* — ``unreachable``, ``auth`` or ``workspace`` — because the same Problem
|
|
23
|
+
line has three different fixes and only one of them is ``deepcell login``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
|
|
30
|
+
import click
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from deepcell_cli import __version__, upgrade_check
|
|
34
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
35
|
+
from deepcell_cli.errors import AccountRequiredError, AuthError, SessionExpiredError
|
|
36
|
+
from deepcell_cli.errors import ConnectionError as CLIConnectionError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _probe_server(api_url: str, timeout: float) -> tuple[bool, str | None]:
|
|
40
|
+
"""Is the API answering? Unauthenticated, so it works before sign-in."""
|
|
41
|
+
try:
|
|
42
|
+
resp = httpx.get(f"{api_url}/health", timeout=timeout)
|
|
43
|
+
except Exception as exc: # network, DNS, TLS — all mean "cannot reach it"
|
|
44
|
+
return False, type(exc).__name__
|
|
45
|
+
if resp.status_code >= 500:
|
|
46
|
+
return False, f"HTTP {resp.status_code}"
|
|
47
|
+
return True, None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _collect(ctx: Ctx, timeout: float, *, probe: bool = True) -> dict:
|
|
51
|
+
"""Gather the setup facts. ``probe=False`` skips the network health check.
|
|
52
|
+
|
|
53
|
+
``--help`` collects the same report but does not show server state, and it
|
|
54
|
+
is typed far more often than `doctor` is run — one avoidable round trip
|
|
55
|
+
there is latency on the most-used command in the CLI.
|
|
56
|
+
"""
|
|
57
|
+
from deepcell_cli import config
|
|
58
|
+
|
|
59
|
+
api_url = config.get_api_url()
|
|
60
|
+
reachable, reason = _probe_server(api_url, timeout) if probe else (True, None)
|
|
61
|
+
|
|
62
|
+
token = config.get_access_token()
|
|
63
|
+
anonymous = config.is_anonymous_session()
|
|
64
|
+
creds = config.load_credentials()
|
|
65
|
+
|
|
66
|
+
if not token:
|
|
67
|
+
identity = "none"
|
|
68
|
+
elif anonymous:
|
|
69
|
+
identity = "anonymous"
|
|
70
|
+
else:
|
|
71
|
+
identity = "account"
|
|
72
|
+
|
|
73
|
+
# Cached answer only — `doctor` must not add a network hop to the package
|
|
74
|
+
# index on top of the server probe it already makes.
|
|
75
|
+
latest = upgrade_check.read_state().get("latest")
|
|
76
|
+
upgrade_available = upgrade_check.is_newer(latest)
|
|
77
|
+
|
|
78
|
+
report: dict = {
|
|
79
|
+
"cli_version": __version__,
|
|
80
|
+
"latest_version": latest if isinstance(latest, str) else None,
|
|
81
|
+
"upgrade_available": upgrade_available,
|
|
82
|
+
"api_url": api_url,
|
|
83
|
+
"server_reachable": reachable,
|
|
84
|
+
"identity": identity,
|
|
85
|
+
"email": None if identity != "account" else creds.get("email"),
|
|
86
|
+
"workspace": ctx.workspace or config.get_active_workspace(),
|
|
87
|
+
"files": None,
|
|
88
|
+
"problems": [],
|
|
89
|
+
"problem_kind": None,
|
|
90
|
+
"available_workspaces": None,
|
|
91
|
+
"next_command": None,
|
|
92
|
+
}
|
|
93
|
+
if not reachable:
|
|
94
|
+
report["problem_kind"] = "unreachable"
|
|
95
|
+
report["problems"].append(
|
|
96
|
+
f"Cannot reach {api_url}"
|
|
97
|
+
+ (f" ({reason})" if reason else "")
|
|
98
|
+
+ ". Check DEEPCELL_API_URL and your network."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Only ask the server about the workspace when there is both a server to ask
|
|
102
|
+
# and a token to ask with — an unauthenticated call here would 401 and read
|
|
103
|
+
# as a workspace problem rather than the sign-in state it actually is.
|
|
104
|
+
if reachable and token and report["workspace"]:
|
|
105
|
+
try:
|
|
106
|
+
files = ctx.client.get(f"/workspaces/{report['workspace']}/files")
|
|
107
|
+
report["files"] = len(files) if isinstance(files, list) else None
|
|
108
|
+
except click.ClickException as exc:
|
|
109
|
+
report["problem_kind"] = _classify(exc)
|
|
110
|
+
report["problems"].append(exc.format_message())
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
report["problem_kind"] = "other"
|
|
113
|
+
report["problems"].append(str(exc))
|
|
114
|
+
|
|
115
|
+
# A workspace the server does not know (or will not show you) is a stale
|
|
116
|
+
# slug in config, not a sign-in problem — `whoami` still works. The fix is
|
|
117
|
+
# to pick a workspace that exists, so fetch the list once and let
|
|
118
|
+
# `next_command` name the slug when there is only one to name.
|
|
119
|
+
if report["problem_kind"] == "workspace":
|
|
120
|
+
try:
|
|
121
|
+
workspaces = ctx.client.get("/workspaces")
|
|
122
|
+
except Exception:
|
|
123
|
+
workspaces = None
|
|
124
|
+
if isinstance(workspaces, list):
|
|
125
|
+
report["available_workspaces"] = [
|
|
126
|
+
w["slug"] for w in workspaces if isinstance(w, dict) and w.get("slug")
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
report["next_command"] = _next_command(report)
|
|
130
|
+
report["ok"] = reachable and not report["problems"]
|
|
131
|
+
return report
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _classify(exc: click.ClickException) -> str:
|
|
135
|
+
"""Which fix a failed workspace probe calls for.
|
|
136
|
+
|
|
137
|
+
Decided from the exception type and status code, never from the English of
|
|
138
|
+
the message — the server rewords ``detail`` freely, and matching on it is
|
|
139
|
+
how a stale slug came to be answered with ``deepcell login`` for months.
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(exc, CLIConnectionError):
|
|
142
|
+
return "unreachable"
|
|
143
|
+
if isinstance(exc, (AuthError, SessionExpiredError, AccountRequiredError)):
|
|
144
|
+
return "auth"
|
|
145
|
+
status = getattr(exc, "status_code", None)
|
|
146
|
+
if status == 401:
|
|
147
|
+
return "auth"
|
|
148
|
+
if status in (403, 404):
|
|
149
|
+
# Not found, or found but not yours: either way the account is fine
|
|
150
|
+
# and the slug is what needs changing.
|
|
151
|
+
return "workspace"
|
|
152
|
+
return "other"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _next_command(report: dict) -> str:
|
|
156
|
+
"""The single most useful thing to run next, given this report.
|
|
157
|
+
|
|
158
|
+
Ordered by what blocks what: an unreachable server makes every other
|
|
159
|
+
answer moot, and a missing workspace blocks work that a missing account
|
|
160
|
+
does not — anonymous sessions are a supported way to start, not a fault.
|
|
161
|
+
"""
|
|
162
|
+
if not report["server_reachable"] or report["problem_kind"] == "unreachable":
|
|
163
|
+
return "deepcell doctor"
|
|
164
|
+
if report["problem_kind"] == "workspace":
|
|
165
|
+
# The saved slug is stale; the account is fine. Name the replacement
|
|
166
|
+
# when there is exactly one, otherwise show the list and let the
|
|
167
|
+
# caller choose — guessing among several writes work somewhere else.
|
|
168
|
+
available = report.get("available_workspaces")
|
|
169
|
+
if available is not None and len(available) == 1:
|
|
170
|
+
return f"deepcell project use {available[0]}"
|
|
171
|
+
if available == [] and report["identity"] == "account":
|
|
172
|
+
return 'deepcell project create "My Project"'
|
|
173
|
+
return "deepcell project list"
|
|
174
|
+
# Only a full account has to create a workspace by hand. With no token the
|
|
175
|
+
# first command mints an anonymous session, and an anonymous session with no
|
|
176
|
+
# workspace gets a scratch one provisioned for it — telling either of them
|
|
177
|
+
# to run `workspace create` would be advice the CLI contradicts one command
|
|
178
|
+
# later.
|
|
179
|
+
if report["workspace"] is None and report["identity"] == "account":
|
|
180
|
+
return 'deepcell project create "My Project"'
|
|
181
|
+
if report["problem_kind"] == "auth":
|
|
182
|
+
return "deepcell login"
|
|
183
|
+
if report["problems"]:
|
|
184
|
+
# Something the probe could not classify (a 5xx on one route, a
|
|
185
|
+
# malformed body). Re-running is the honest advice: it is not a
|
|
186
|
+
# sign-in problem, and `login` is a browser round trip that cannot
|
|
187
|
+
# fix it.
|
|
188
|
+
return "deepcell doctor"
|
|
189
|
+
# Not `guide quick-start`: that topic was a finance template index that
|
|
190
|
+
# opened by redirecting the reader here, and has since been deleted.
|
|
191
|
+
return "deepcell guide orient/start"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _render_plain(report: dict) -> list[str]:
|
|
195
|
+
version_line = f"deepcell {report['cli_version']}"
|
|
196
|
+
if report.get("upgrade_available"):
|
|
197
|
+
version_line += (
|
|
198
|
+
f" ({report['latest_version']} available — deepcell upgrade)"
|
|
199
|
+
)
|
|
200
|
+
lines = [version_line, f" API {report['api_url']}"]
|
|
201
|
+
lines.append(
|
|
202
|
+
f" Server {'reachable' if report['server_reachable'] else 'UNREACHABLE'}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if report["identity"] in ("none", "anonymous"):
|
|
206
|
+
lines.append(" Identity not signed in")
|
|
207
|
+
else:
|
|
208
|
+
lines.append(f" Identity {report['email'] or 'signed in'}")
|
|
209
|
+
|
|
210
|
+
if report["workspace"]:
|
|
211
|
+
count = report["files"]
|
|
212
|
+
suffix = "" if count is None else f" ({count} file{'s' if count != 1 else ''})"
|
|
213
|
+
lines.append(f" Workspace {report['workspace']}{suffix}")
|
|
214
|
+
else:
|
|
215
|
+
lines.append(" Workspace none yet — one is created for you on first use")
|
|
216
|
+
|
|
217
|
+
for problem in report["problems"]:
|
|
218
|
+
lines.append(f" Problem {problem}")
|
|
219
|
+
|
|
220
|
+
available = report.get("available_workspaces")
|
|
221
|
+
if available:
|
|
222
|
+
shown = ", ".join(available[:5])
|
|
223
|
+
if len(available) > 5:
|
|
224
|
+
shown += f", … ({len(available)} total)"
|
|
225
|
+
lines.append(f" Available {shown}")
|
|
226
|
+
|
|
227
|
+
lines.append(f"\nNext: {report['next_command']}")
|
|
228
|
+
return lines
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@click.command()
|
|
232
|
+
@click.option(
|
|
233
|
+
"--timeout",
|
|
234
|
+
type=float,
|
|
235
|
+
default=10.0,
|
|
236
|
+
show_default=True,
|
|
237
|
+
metavar="SECONDS",
|
|
238
|
+
help="How long to wait for the server probe.",
|
|
239
|
+
)
|
|
240
|
+
@pass_ctx
|
|
241
|
+
def doctor(ctx: Ctx, timeout: float) -> None:
|
|
242
|
+
"""Check this machine's setup: version, server, identity, workspace.
|
|
243
|
+
|
|
244
|
+
Reports what is configured and what to run next. Changes nothing, and exits
|
|
245
|
+
0 even when nothing is set up yet — "not signed in" is an answer.
|
|
246
|
+
|
|
247
|
+
\b
|
|
248
|
+
Examples:
|
|
249
|
+
deepcell doctor
|
|
250
|
+
deepcell doctor --format json # next_command says what to run
|
|
251
|
+
"""
|
|
252
|
+
report = _collect(ctx, timeout)
|
|
253
|
+
if ctx.fmt == "json":
|
|
254
|
+
click.echo(json.dumps(report, indent=2))
|
|
255
|
+
return
|
|
256
|
+
for line in _render_plain(report):
|
|
257
|
+
click.echo(line)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Download command: save a workspace file to the local filesystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
8
|
+
from deepcell_cli.output import echo_success
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("filename")
|
|
13
|
+
@click.option("-o", "--output", "out_path", default=None, help="Output file path (default: same as filename).")
|
|
14
|
+
@click.option("--revision", default=None, help="Download file at specific revision.")
|
|
15
|
+
@pass_ctx
|
|
16
|
+
def download(ctx: Ctx, filename: str, out_path: str | None, revision: str | None) -> None:
|
|
17
|
+
"""Download a file from the workspace to the local filesystem."""
|
|
18
|
+
slug = ctx.require_workspace()
|
|
19
|
+
params: dict = {}
|
|
20
|
+
if revision:
|
|
21
|
+
params["revision"] = revision
|
|
22
|
+
data = ctx.client.get(f"/workspaces/{slug}/files/{filename}", params=params or None)
|
|
23
|
+
if not (isinstance(data, dict) and isinstance(data.get("content"), str)):
|
|
24
|
+
# Never write a repr of the response envelope to disk as if it were
|
|
25
|
+
# the file — that's a corrupt download masquerading as success.
|
|
26
|
+
raise click.ClickException(
|
|
27
|
+
f"Unexpected response for '{filename}' — no file content returned."
|
|
28
|
+
)
|
|
29
|
+
content = data["content"]
|
|
30
|
+
if not content:
|
|
31
|
+
raise click.ClickException(f"File '{filename}' is empty or not found.")
|
|
32
|
+
if not out_path:
|
|
33
|
+
out_path = filename
|
|
34
|
+
with open(out_path, "w") as fh:
|
|
35
|
+
fh.write(content)
|
|
36
|
+
echo_success(f"Downloaded to {out_path} ({len(content)} bytes)")
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Edit command: single/batch value edit of .deepcell files.
|
|
2
|
+
|
|
3
|
+
String replacement lives in ``commands/replace.py`` — it is its own command
|
|
4
|
+
(``deepcell replace``) because its exit-1 means something else entirely. The
|
|
5
|
+
``--replace`` flag here is a deprecated alias kept for existing scripts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from deepcell_cli.commands._batch_input import read_batch_payload
|
|
15
|
+
from deepcell_cli.commands._write_opts import NegativeNumberWriteCommand
|
|
16
|
+
from deepcell_cli.commands._version_display import echo_version_history
|
|
17
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
18
|
+
from deepcell_cli.output import (
|
|
19
|
+
echo_error,
|
|
20
|
+
echo_info,
|
|
21
|
+
echo_query_back_hint,
|
|
22
|
+
echo_success,
|
|
23
|
+
echo_warning,
|
|
24
|
+
output,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Mirrors BatchEditRequest.change_details max_length in
|
|
28
|
+
# backend/jingwei_api/routers/jingwei.py — keep in sync.
|
|
29
|
+
MAX_CHANGES_PER_BATCH = 1000
|
|
30
|
+
|
|
31
|
+
# Accepted snake_case spellings of the camelCase wire fields on a batch row
|
|
32
|
+
# (BatchEditRequest / CellRef declare literal camelCase names, no aliases).
|
|
33
|
+
_SNAKE_TO_CAMEL_KEYS = {
|
|
34
|
+
"item_ref": "itemRef",
|
|
35
|
+
"context_ref": "contextRef",
|
|
36
|
+
"status_ref": "statusRef",
|
|
37
|
+
"scenario_ref": "scenarioRef",
|
|
38
|
+
"custom_dimensions": "customDimensions",
|
|
39
|
+
"new_value": "newValue",
|
|
40
|
+
"is_force_editable": "isForceEditable",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.command(cls=NegativeNumberWriteCommand)
|
|
45
|
+
@click.argument("filename")
|
|
46
|
+
@click.argument("item_ref", required=False)
|
|
47
|
+
@click.argument("context_ref", required=False)
|
|
48
|
+
@click.argument("new_value", required=False)
|
|
49
|
+
@click.option("--force", is_flag=True, help="Type over a calculated cell, REMOVING the formula that computed it (no in-document undo).")
|
|
50
|
+
@click.option(
|
|
51
|
+
"--clear",
|
|
52
|
+
"clear_cell",
|
|
53
|
+
is_flag=True,
|
|
54
|
+
help="Remove the literal value cell (NEW_VALUE omitted) so a CalcDef can re-govern it.",
|
|
55
|
+
)
|
|
56
|
+
@click.option("--status", "status_ref", default=None, help="Status reference (e.g. 'projected').")
|
|
57
|
+
@click.option("--scenario", "scenario_ref", default=None, help="Scenario dimension — must be a defined scenarioId (omit for the base cell).")
|
|
58
|
+
@click.option(
|
|
59
|
+
"--custom-dimensions",
|
|
60
|
+
"custom_dimensions",
|
|
61
|
+
default=None,
|
|
62
|
+
help="Custom dimensions as 'dim:member;dim:member', e.g. 'geography:na;product_line:ent'.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--batch",
|
|
66
|
+
"batch_file",
|
|
67
|
+
help="Batch edits as a JSON file path, '-' for stdin, or inline JSON "
|
|
68
|
+
"(a value starting with '[' or '{').",
|
|
69
|
+
)
|
|
70
|
+
@click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
|
|
71
|
+
@click.option(
|
|
72
|
+
"-m",
|
|
73
|
+
"--message",
|
|
74
|
+
"--rationale",
|
|
75
|
+
"commit_message",
|
|
76
|
+
default=None,
|
|
77
|
+
help=(
|
|
78
|
+
"Why this edit was made. Written as the commit message body (and a "
|
|
79
|
+
"trailer), so history reads as the decision instead of "
|
|
80
|
+
"'[batch-edit] Item[Ctx]'. Sent as `rationale`; `-m` / `--message` are "
|
|
81
|
+
"aliases of `--rationale`."
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--title",
|
|
86
|
+
"commit_title",
|
|
87
|
+
default=None,
|
|
88
|
+
help="Short commit subject (e.g. 'Q3 actuals update'). Combined with "
|
|
89
|
+
"--rationale as 'title: rationale'.",
|
|
90
|
+
)
|
|
91
|
+
@click.option("--auto-create-context", is_flag=True, help="Auto-create missing context definitions.")
|
|
92
|
+
@click.option("--replace", "replace_mode", is_flag=True, help="DEPRECATED alias for `deepcell replace FILE OLD NEW` — still works, will be removed in a future release. Note the exit codes listed here are `edit`'s: in replace mode, exit 1 means the replacement IS already in the file and the document is invalid.")
|
|
93
|
+
@click.option("--replace-all", is_flag=True, help="Replace all occurrences (with the deprecated --replace).")
|
|
94
|
+
@click.option("--workspace", "workspace_slug", help="Override active workspace.")
|
|
95
|
+
@pass_ctx
|
|
96
|
+
def edit(
|
|
97
|
+
ctx: Ctx,
|
|
98
|
+
filename: str,
|
|
99
|
+
item_ref: str | None,
|
|
100
|
+
context_ref: str | None,
|
|
101
|
+
new_value: str | None,
|
|
102
|
+
force: bool,
|
|
103
|
+
clear_cell: bool,
|
|
104
|
+
status_ref: str | None,
|
|
105
|
+
scenario_ref: str | None,
|
|
106
|
+
custom_dimensions: str | None,
|
|
107
|
+
batch_file: str | None,
|
|
108
|
+
revision: str | None,
|
|
109
|
+
commit_message: str | None,
|
|
110
|
+
commit_title: str | None,
|
|
111
|
+
auto_create_context: bool,
|
|
112
|
+
replace_mode: bool,
|
|
113
|
+
replace_all: bool,
|
|
114
|
+
workspace_slug: str | None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Write literal cell values (assumptions, historical actuals).
|
|
117
|
+
|
|
118
|
+
\b
|
|
119
|
+
Editing modes — pick the right tool:
|
|
120
|
+
Values (this command) hardcoded inputs: assumptions, historicals
|
|
121
|
+
Structure / formulas `deepcell defs add-calc / add-item / ...`
|
|
122
|
+
Raw XML `deepcell replace FILE OLD NEW`
|
|
123
|
+
|
|
124
|
+
\b
|
|
125
|
+
Derived metrics belong in a CalculationDefinition, NOT here. The
|
|
126
|
+
backend recomputes every CalcDef on read — see `deepcell guide
|
|
127
|
+
calc-engine` for the paradigm.
|
|
128
|
+
|
|
129
|
+
\b
|
|
130
|
+
Value mode (default):
|
|
131
|
+
deepcell edit model.deepcell Revenue_Growth FY2025E 0.12
|
|
132
|
+
deepcell edit model.deepcell ResearchSpend FY2025E -166667 # negatives work
|
|
133
|
+
deepcell edit model.deepcell --batch changes.json
|
|
134
|
+
deepcell edit model.deepcell --batch '[{"itemRef":"Revenue","contextRef":"FY2025E","newValue":"120"}]'
|
|
135
|
+
|
|
136
|
+
\b
|
|
137
|
+
Record WHY, not just what — the rationale becomes the commit message, so
|
|
138
|
+
`deepcell log` reads as the decision rather than the coordinates:
|
|
139
|
+
deepcell edit model.deepcell Price FY2025E 42 -m "wk3 price decision"
|
|
140
|
+
|
|
141
|
+
\b
|
|
142
|
+
Scenario / custom-dimension cells (omit the flag to target the base cell):
|
|
143
|
+
deepcell edit model.deepcell Revenue FY2025E 120 --scenario bull
|
|
144
|
+
deepcell edit model.deepcell Revenue FY2025E 80 --custom-dimensions "geography:na"
|
|
145
|
+
|
|
146
|
+
\b
|
|
147
|
+
In batch mode, --scenario/--status/--custom-dimensions are per-row
|
|
148
|
+
defaults: they fill every row that does not set the key itself, and a
|
|
149
|
+
row-level scenarioRef/statusRef/customDimensions always wins (an
|
|
150
|
+
explicit null targets the base cell):
|
|
151
|
+
deepcell edit model.deepcell --batch rows.json --scenario low
|
|
152
|
+
|
|
153
|
+
\b
|
|
154
|
+
Clear mode (remove a literal cell so a CalcDef can re-govern it):
|
|
155
|
+
deepcell edit model.deepcell Cash FY2025E --clear
|
|
156
|
+
deepcell edit model.deepcell Revenue FY2025E --clear --scenario bull
|
|
157
|
+
|
|
158
|
+
\b
|
|
159
|
+
Replace mode is DEPRECATED — `deepcell edit FILE --replace OLD NEW` is now
|
|
160
|
+
`deepcell replace FILE OLD NEW`. The alias still works and warns.
|
|
161
|
+
|
|
162
|
+
\b
|
|
163
|
+
--force types over a CalcDef-computed cell, and REMOVES the formula that
|
|
164
|
+
computed it: the CalcDef is deleted when the cell is all it drives, or the
|
|
165
|
+
cell's period is dropped from its contextRefs when it drives more. Where
|
|
166
|
+
neither can be expressed the edit is refused and the reason is reported.
|
|
167
|
+
There is no in-document undo — the removed formula is echoed back, and
|
|
168
|
+
`git` has the rest. Use it rarely; usually the fix is to correct the
|
|
169
|
+
CalcDef instead. Manually edited values do not carry <Source> provenance.
|
|
170
|
+
|
|
171
|
+
\b
|
|
172
|
+
Two ways to set scenario values:
|
|
173
|
+
--scenario ID writes one scenario-specific cell; ID must exist in
|
|
174
|
+
ScenarioDefinitions (ids are case-sensitive)
|
|
175
|
+
VariableOverride formula/bulk overrides, managed via ScenarioDefinitions
|
|
176
|
+
(`deepcell guide revise/scenarios`)
|
|
177
|
+
Base-case values stay untagged — don't pass the default scenario's id;
|
|
178
|
+
a sheet's default-scenario column reads the untagged cell directly, and
|
|
179
|
+
a non-default column inherits it wherever the scenario stores no cell.
|
|
180
|
+
"""
|
|
181
|
+
slug = workspace_slug or ctx.require_workspace()
|
|
182
|
+
|
|
183
|
+
if replace_mode:
|
|
184
|
+
# Deprecated alias. `replace` is its own command because its exit-1
|
|
185
|
+
# sense ("written, then found invalid") is not `edit`'s ("partially
|
|
186
|
+
# applied") — see cli/src/deepcell_cli/surface.py:EXIT_SENSE_RULES.
|
|
187
|
+
from deepcell_cli.commands.replace import do_replace
|
|
188
|
+
|
|
189
|
+
echo_warning(
|
|
190
|
+
"`edit --replace` is deprecated — use "
|
|
191
|
+
f"`deepcell replace {filename} OLD NEW` instead. The alias still "
|
|
192
|
+
"works and will be removed in a future release."
|
|
193
|
+
)
|
|
194
|
+
do_replace(
|
|
195
|
+
ctx, slug, filename, item_ref, context_ref, batch_file, replace_all,
|
|
196
|
+
revision, commit_title, commit_message,
|
|
197
|
+
usage="deepcell edit FILE --replace OLD NEW",
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if batch_file:
|
|
202
|
+
# Batch mode (inline JSON, stdin, or file path)
|
|
203
|
+
raw = read_batch_payload(batch_file)
|
|
204
|
+
try:
|
|
205
|
+
changes = json.loads(raw)
|
|
206
|
+
except json.JSONDecodeError as exc:
|
|
207
|
+
raise click.UsageError(f"Batch JSON does not parse — {exc}") from exc
|
|
208
|
+
if not isinstance(changes, list):
|
|
209
|
+
raise click.UsageError("Batch input must be a JSON array of change objects.")
|
|
210
|
+
# Mirrors BatchEditRequest.change_details max_length. Checked here so a
|
|
211
|
+
# 2000-cell import says how to fix it instead of failing wholesale with
|
|
212
|
+
# a raw pydantic 422.
|
|
213
|
+
if len(changes) > MAX_CHANGES_PER_BATCH:
|
|
214
|
+
raise click.ClickException(
|
|
215
|
+
f"{len(changes)} changes exceeds the server limit of "
|
|
216
|
+
f"{MAX_CHANGES_PER_BATCH} per request. Split the file into "
|
|
217
|
+
f"batches of {MAX_CHANGES_PER_BATCH} rows and edit each in turn."
|
|
218
|
+
)
|
|
219
|
+
# The wire shape is camelCase — BatchEditRequest / CellRef declare
|
|
220
|
+
# literal field names, no aliases. Agents routinely spell rows in
|
|
221
|
+
# snake_case (an eval worker's entire edit died on `item_ref` vs
|
|
222
|
+
# `itemRef` and the starter model shipped unchanged), and the intent
|
|
223
|
+
# is unambiguous, so normalize client-side instead of 422ing.
|
|
224
|
+
for row in changes:
|
|
225
|
+
if not isinstance(row, dict):
|
|
226
|
+
continue
|
|
227
|
+
for snake, camel in _SNAKE_TO_CAMEL_KEYS.items():
|
|
228
|
+
if snake in row:
|
|
229
|
+
value = row.pop(snake)
|
|
230
|
+
row.setdefault(camel, value)
|
|
231
|
+
# --scenario/--status/--custom-dimensions/--force/--clear act as
|
|
232
|
+
# per-row defaults: a row that sets the key itself (even to null,
|
|
233
|
+
# meaning the base cell) wins. `--force`/`--clear` used to be dropped
|
|
234
|
+
# here, so `--batch rows.json --force` was accepted and every
|
|
235
|
+
# calculated-cell edit failed with "cell is calculated".
|
|
236
|
+
flag_defaults = {
|
|
237
|
+
"statusRef": status_ref,
|
|
238
|
+
"scenarioRef": scenario_ref,
|
|
239
|
+
"customDimensions": custom_dimensions,
|
|
240
|
+
"isForceEditable": True if force else None,
|
|
241
|
+
"clear": True if clear_cell else None,
|
|
242
|
+
}
|
|
243
|
+
for key, flag_value in flag_defaults.items():
|
|
244
|
+
if flag_value is None:
|
|
245
|
+
continue
|
|
246
|
+
for row in changes:
|
|
247
|
+
if isinstance(row, dict) and key not in row:
|
|
248
|
+
row[key] = flag_value
|
|
249
|
+
elif clear_cell and item_ref and context_ref:
|
|
250
|
+
# Clear a single literal cell — NEW_VALUE is omitted.
|
|
251
|
+
change = {
|
|
252
|
+
"itemRef": item_ref,
|
|
253
|
+
"contextRef": context_ref,
|
|
254
|
+
"clear": True,
|
|
255
|
+
}
|
|
256
|
+
if status_ref:
|
|
257
|
+
change["statusRef"] = status_ref
|
|
258
|
+
# scenario / customDimensions are added only when provided so the body
|
|
259
|
+
# stays byte-minimal for base-cell edits (keys match the pydantic
|
|
260
|
+
# field names — see docs/backend-cli-contract.md).
|
|
261
|
+
if scenario_ref:
|
|
262
|
+
change["scenarioRef"] = scenario_ref
|
|
263
|
+
if custom_dimensions:
|
|
264
|
+
change["customDimensions"] = custom_dimensions
|
|
265
|
+
changes = [change]
|
|
266
|
+
elif item_ref and context_ref and new_value is not None:
|
|
267
|
+
# Single edit
|
|
268
|
+
change: dict = {
|
|
269
|
+
"itemRef": item_ref,
|
|
270
|
+
"contextRef": context_ref,
|
|
271
|
+
"newValue": new_value,
|
|
272
|
+
"isForceEditable": force,
|
|
273
|
+
}
|
|
274
|
+
if status_ref:
|
|
275
|
+
change["statusRef"] = status_ref
|
|
276
|
+
if scenario_ref:
|
|
277
|
+
change["scenarioRef"] = scenario_ref
|
|
278
|
+
if custom_dimensions:
|
|
279
|
+
change["customDimensions"] = custom_dimensions
|
|
280
|
+
changes = [change]
|
|
281
|
+
else:
|
|
282
|
+
raise click.UsageError(
|
|
283
|
+
"Provide ITEM_REF CONTEXT_REF NEW_VALUE for single edit, "
|
|
284
|
+
"ITEM_REF CONTEXT_REF --clear to clear a cell, or --batch for batch."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
body: dict = {
|
|
288
|
+
"workspace_slug": slug,
|
|
289
|
+
"filename": filename,
|
|
290
|
+
"change_details": changes,
|
|
291
|
+
}
|
|
292
|
+
if revision:
|
|
293
|
+
body["expected_revision"] = revision
|
|
294
|
+
if auto_create_context:
|
|
295
|
+
body["auto_create_context"] = True
|
|
296
|
+
# Keys match BatchEditRequest.title / .rationale (docs/backend-cli-contract.md).
|
|
297
|
+
if commit_title:
|
|
298
|
+
body["title"] = commit_title
|
|
299
|
+
if commit_message:
|
|
300
|
+
body["rationale"] = commit_message
|
|
301
|
+
|
|
302
|
+
from deepcell_cli.errors import APIError
|
|
303
|
+
from deepcell_cli.revision import raise_if_stale
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
data = ctx.client.post("/batch-edit", json=body)
|
|
307
|
+
except APIError as exc:
|
|
308
|
+
# This command sends expected_revision but never rendered the 409 it
|
|
309
|
+
# earns, so a conflict arrived as a bare one-line "Conflict: …" with no
|
|
310
|
+
# statement of whether the edit was saved and no revision to re-read
|
|
311
|
+
# at — on the command an agent is most likely to run concurrently.
|
|
312
|
+
raise_if_stale(exc, filename=filename)
|
|
313
|
+
raise
|
|
314
|
+
output(data, ctx.fmt)
|
|
315
|
+
|
|
316
|
+
# Show summary
|
|
317
|
+
if isinstance(data, dict):
|
|
318
|
+
n_ok = len(data.get("results", []))
|
|
319
|
+
n_err = len(data.get("errors", []))
|
|
320
|
+
auto = data.get("auto_created_contexts", [])
|
|
321
|
+
if auto:
|
|
322
|
+
echo_info(f"auto-created {len(auto)} context(s): {', '.join(auto)}")
|
|
323
|
+
if n_err:
|
|
324
|
+
echo_error(f"{n_ok} edit(s) applied, {n_err} error(s)")
|
|
325
|
+
# Hint: if any errors are context-not-found and flag wasn't used, suggest it
|
|
326
|
+
if not auto_create_context:
|
|
327
|
+
has_ctx_error = any(
|
|
328
|
+
"not found" in e.get("error", "") and "Context" in e.get("error", "")
|
|
329
|
+
for e in data.get("errors", [])
|
|
330
|
+
)
|
|
331
|
+
if has_ctx_error:
|
|
332
|
+
echo_info("hint: use --auto-create-context to auto-create missing contexts")
|
|
333
|
+
has_scenario_error = any(
|
|
334
|
+
"not found" in e.get("error", "") and "Scenario" in e.get("error", "")
|
|
335
|
+
for e in data.get("errors", [])
|
|
336
|
+
)
|
|
337
|
+
if has_scenario_error:
|
|
338
|
+
echo_info(
|
|
339
|
+
"hint: scenario ids are case-sensitive; define scenarios first "
|
|
340
|
+
"with 'deepcell defs add-scenario'"
|
|
341
|
+
)
|
|
342
|
+
elif ctx.fmt != "plain":
|
|
343
|
+
# In plain mode the stdout summary from _format_plain_dict already
|
|
344
|
+
# reads "N edit(s) applied rev:abc12345" — don't duplicate on stderr.
|
|
345
|
+
echo_success(f"{n_ok} edit(s) applied")
|
|
346
|
+
|
|
347
|
+
# A clear reports how many cells it removed; 0 means the coordinates
|
|
348
|
+
# matched nothing (e.g. --scenario bull when the literal is stored
|
|
349
|
+
# untagged on the base cell). The row is still status="success", so
|
|
350
|
+
# without this the no-op read exactly like a successful clear.
|
|
351
|
+
for r in data.get("results", []):
|
|
352
|
+
if not isinstance(r, dict) or "cleared" not in r:
|
|
353
|
+
continue
|
|
354
|
+
if r.get("cleared"):
|
|
355
|
+
continue
|
|
356
|
+
coords = " ".join(
|
|
357
|
+
str(p) for p in (r.get("itemRef"), r.get("contextRef")) if p
|
|
358
|
+
)
|
|
359
|
+
echo_warning(
|
|
360
|
+
f"nothing was cleared for {coords or 'the requested cell'} — "
|
|
361
|
+
f"no matching value cell at those coordinates. Check "
|
|
362
|
+
f"--scenario / --status / --custom-dimensions: a literal "
|
|
363
|
+
f"stored on the base cell is not matched by a scoped clear."
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Show warnings (skip formula-mismatch noise)
|
|
367
|
+
for w in data.get("warnings", []):
|
|
368
|
+
if "mismatched values" in w:
|
|
369
|
+
continue
|
|
370
|
+
echo_warning(f"{w}")
|
|
371
|
+
|
|
372
|
+
# Eval S1 / R4: a value just changed — surface the query-back on the
|
|
373
|
+
# exact cell written, at the one moment it is due.
|
|
374
|
+
if not n_err and changes:
|
|
375
|
+
echo_query_back_hint(
|
|
376
|
+
filename,
|
|
377
|
+
item=changes[0].get("itemRef"),
|
|
378
|
+
context=changes[0].get("contextRef"),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
echo_version_history(data)
|
|
382
|
+
|
|
383
|
+
if n_err:
|
|
384
|
+
raise click.exceptions.Exit(1)
|