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