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,279 @@
|
|
|
1
|
+
"""``deepcell ref`` — generated enumerations, and the cross-reference resolver.
|
|
2
|
+
|
|
3
|
+
Where ``guide`` explains a procedure and ``rules`` states an invariant, ``ref``
|
|
4
|
+
answers "what values are legal here". Its content is derived from the
|
|
5
|
+
implementation — the functions from the formula constants, the op kinds from
|
|
6
|
+
the pydantic union the server validates against — so unlike a prose list it
|
|
7
|
+
cannot disagree with the code.
|
|
8
|
+
|
|
9
|
+
The second job is resolution: ``deepcell ref rule:R2`` and
|
|
10
|
+
``deepcell ref guide:generate/calcs`` both work, so an agent holding a typed id
|
|
11
|
+
from a lint finding or a topic's ``see_also`` has exactly one command to reach
|
|
12
|
+
for and never has to work out which surface owns the name.
|
|
13
|
+
|
|
14
|
+
See ``docs/cli-agent-surface.md`` §6.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
22
|
+
from deepcell_cli.output import output, print_plain
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_cmd_id(raw_id: str) -> dict:
|
|
26
|
+
"""Resolve ``cmd:<invocation>`` against the live Click tree.
|
|
27
|
+
|
|
28
|
+
Raises rather than returning a stub for an unknown name: a resolver that
|
|
29
|
+
answers for every string is worse than none, because it would confirm a
|
|
30
|
+
citation to a command that does not exist — which is the failure the typed
|
|
31
|
+
id scheme is meant to make impossible.
|
|
32
|
+
"""
|
|
33
|
+
from deepcell_cli.surface import build_commands
|
|
34
|
+
|
|
35
|
+
name = raw_id.partition(":")[2].strip()
|
|
36
|
+
commands = build_commands()
|
|
37
|
+
entry = commands.get(name)
|
|
38
|
+
if entry is None:
|
|
39
|
+
raise click.ClickException(
|
|
40
|
+
f"Cannot resolve id: {raw_id}\n"
|
|
41
|
+
f"No command named {name!r}. List them with: deepcell help"
|
|
42
|
+
)
|
|
43
|
+
return {
|
|
44
|
+
"id": f"cmd:{name}",
|
|
45
|
+
"kind": "cmd",
|
|
46
|
+
"name": name,
|
|
47
|
+
"summary": entry.get("summary", ""),
|
|
48
|
+
"command": f"deepcell {name} --help",
|
|
49
|
+
"example": entry.get("example"),
|
|
50
|
+
"see_also": entry.get("see_also") or [],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_entry(entry: dict) -> str:
|
|
55
|
+
"""Format one resolved entry for human reading."""
|
|
56
|
+
lines: list[str] = []
|
|
57
|
+
header = entry.get("id") or entry.get("name") or ""
|
|
58
|
+
if header:
|
|
59
|
+
lines += [header, "=" * len(header), ""]
|
|
60
|
+
|
|
61
|
+
if entry.get("summary"):
|
|
62
|
+
lines += [str(entry["summary"]), ""]
|
|
63
|
+
|
|
64
|
+
for label, key in (
|
|
65
|
+
("Signature", "signature"),
|
|
66
|
+
("Arity", "arity"),
|
|
67
|
+
("Category", "category"),
|
|
68
|
+
("Severity", "severity"),
|
|
69
|
+
("Command", "command"),
|
|
70
|
+
("Endpoint", "endpoint"),
|
|
71
|
+
("Family", "family"),
|
|
72
|
+
("Model", "model"),
|
|
73
|
+
# A deck style pack's metadata (`ref:deck-style/<pack>`): the five
|
|
74
|
+
# fields to choose a pack by, printed before the prose so a reader
|
|
75
|
+
# comparing packs sees the same shape on each.
|
|
76
|
+
("Audience", "audience"),
|
|
77
|
+
("Formality", "formality"),
|
|
78
|
+
("Density", "density"),
|
|
79
|
+
("Reading", "reading_mode"),
|
|
80
|
+
("Export", "export_fidelity"),
|
|
81
|
+
):
|
|
82
|
+
value = entry.get(key)
|
|
83
|
+
if value:
|
|
84
|
+
lines.append(f"{label + ':':<12}{value}")
|
|
85
|
+
roles = entry.get("roles")
|
|
86
|
+
if isinstance(roles, dict) and roles:
|
|
87
|
+
lines.append(
|
|
88
|
+
f"{'Roles:':<12}" + " ".join(f"{name}={value}" for name, value in roles.items())
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if entry.get("aliases"):
|
|
92
|
+
lines.append(f"{'Aliases:':<12}{', '.join(entry['aliases'])}")
|
|
93
|
+
if entry.get("house_rule"):
|
|
94
|
+
lines.append(
|
|
95
|
+
f"{'House rule:':<12}rule:{entry['house_rule']} — {entry.get('house_rule_title', '')}"
|
|
96
|
+
)
|
|
97
|
+
if entry.get("resolved_from"):
|
|
98
|
+
lines.append(f"{'Resolved:':<12}{entry['resolved_from']} → {entry['name']}")
|
|
99
|
+
|
|
100
|
+
for label, key in (("Required", "required"), ("Optional", "optional")):
|
|
101
|
+
fields = entry.get(key)
|
|
102
|
+
if fields:
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.append(f"{label}:")
|
|
105
|
+
lines += [f" {f['name']}: {f['type']}" for f in fields]
|
|
106
|
+
|
|
107
|
+
if entry.get("fix"):
|
|
108
|
+
lines += ["", "Fix:", f" {entry['fix']}"]
|
|
109
|
+
if entry.get("detail"):
|
|
110
|
+
lines += ["", str(entry["detail"])]
|
|
111
|
+
|
|
112
|
+
# A deck style pack's starters (`ref:deck-style/<pack>`) are drop-in
|
|
113
|
+
# strings; fenced so a terminal reader gets exactly what the `-f json`
|
|
114
|
+
# field carries and can pass it through unchanged.
|
|
115
|
+
for label, key, lang in (
|
|
116
|
+
("Deck CSS — pass through set_presentation_deck_style verbatim", "css", "css"),
|
|
117
|
+
("Slide HTML starter", "slide_html", "html"),
|
|
118
|
+
# A deck layout (`ref:deck-layout/<name>`): the class-only slide body
|
|
119
|
+
# `deck add-slide --layout` inserts, fenced for the same reason.
|
|
120
|
+
("Layout HTML — class-only, on the shared vocabulary", "html", "html"),
|
|
121
|
+
):
|
|
122
|
+
value = entry.get(key)
|
|
123
|
+
if value:
|
|
124
|
+
lines += ["", f"{label}:", f"```{lang}", str(value).rstrip("\n"), "```"]
|
|
125
|
+
|
|
126
|
+
for sense in entry.get("senses") or []:
|
|
127
|
+
lines += [
|
|
128
|
+
"",
|
|
129
|
+
f" [{sense['id']}] {sense['meaning']}",
|
|
130
|
+
f" commands: {', '.join(sense['commands'])}",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
if entry.get("over_mcp"):
|
|
134
|
+
lines += ["", str(entry["over_mcp"])]
|
|
135
|
+
if entry.get("body"):
|
|
136
|
+
lines += ["", str(entry["body"]).rstrip()]
|
|
137
|
+
|
|
138
|
+
return "\n".join(lines).rstrip()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@click.command()
|
|
142
|
+
@click.argument("target", nargs=-1)
|
|
143
|
+
@click.option("--limit", default=40, show_default=True, help="Maximum search results.")
|
|
144
|
+
@pass_ctx
|
|
145
|
+
def ref(ctx: Ctx, target: tuple[str, ...], limit: int) -> None:
|
|
146
|
+
"""Look up legal values, and resolve any typed id.
|
|
147
|
+
|
|
148
|
+
\b
|
|
149
|
+
List the namespaces: deepcell ref
|
|
150
|
+
List one namespace: deepcell ref function
|
|
151
|
+
Read one entry: deepcell ref function/NPV
|
|
152
|
+
Resolve a typed id: deepcell ref rule:R2
|
|
153
|
+
Search everything: deepcell ref search sensitivity
|
|
154
|
+
|
|
155
|
+
Search is a leading word rather than a subcommand: a Click group with an
|
|
156
|
+
optional positional cannot tell `ref search` (the verb) from `ref search`
|
|
157
|
+
(a namespace called "search"), and the ambiguity surfaces as a confusing
|
|
158
|
+
"No such command" on the *second* word.
|
|
159
|
+
"""
|
|
160
|
+
if target and target[0] == "search":
|
|
161
|
+
_search(ctx, " ".join(target[1:]).strip(), limit)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
if not target:
|
|
165
|
+
_list_namespaces(ctx)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
what = target[0]
|
|
169
|
+
|
|
170
|
+
# A typed id (`rule:R2`, `ref:function/NPV`) resolves across every surface;
|
|
171
|
+
# a bare `function` or `function/NPV` addresses this surface directly.
|
|
172
|
+
#
|
|
173
|
+
# A colon alone does not make an id: `ref:selector/range` documents
|
|
174
|
+
# `Item[CURRENT-11:CURRENT]`, so a reader pasting what they typed asks for
|
|
175
|
+
# `selector/CURRENT-11:CURRENT`, which resolved to the nonexistent prefix
|
|
176
|
+
# `selector/CURRENT-11` and answered "cannot resolve" to a *legal*
|
|
177
|
+
# selector. A typed id's prefix is a fixed word and so never contains a
|
|
178
|
+
# `/`; a `/` before the first `:` means this is a namespace path whose name
|
|
179
|
+
# holds a colon. The authority is `src.core.ref.ids.looks_like_typed_id` —
|
|
180
|
+
# duplicated here because this package cannot import the backend, the same
|
|
181
|
+
# way the two `guide` index renderers agree by rule rather than by import.
|
|
182
|
+
if ":" in what and "/" not in what.partition(":")[0]:
|
|
183
|
+
raw_id = " ".join(target).strip()
|
|
184
|
+
if raw_id.startswith("cmd:"):
|
|
185
|
+
# Answered here, not by /ref/resolve. The command catalog is built
|
|
186
|
+
# from the live Click tree, which exists only in this package —
|
|
187
|
+
# backend/'s Docker build context excludes docs/cli-surface.json,
|
|
188
|
+
# so a server-side resolver would need a shipped copy of the tree
|
|
189
|
+
# and could drift from the commands that actually exist. This one
|
|
190
|
+
# cannot be wrong about that.
|
|
191
|
+
_print_entry(ctx, _resolve_cmd_id(raw_id))
|
|
192
|
+
return
|
|
193
|
+
_print_entry(ctx, ctx.client.get("/ref/resolve", params={"id": raw_id}))
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
namespace, _, name = what.partition("/")
|
|
197
|
+
if name:
|
|
198
|
+
_print_entry(ctx, ctx.client.get(f"/ref/{namespace}/{name}"))
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
_list_entries(ctx, namespace)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _print_entry(ctx: Ctx, data: object) -> None:
|
|
205
|
+
if ctx.fmt == "json":
|
|
206
|
+
output(data, ctx.fmt)
|
|
207
|
+
return
|
|
208
|
+
print_plain(_render_entry(data) if isinstance(data, dict) else str(data))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _list_namespaces(ctx: Ctx) -> None:
|
|
212
|
+
data = ctx.client.get("/ref")
|
|
213
|
+
namespaces = data.get("namespaces", []) if isinstance(data, dict) else data
|
|
214
|
+
if ctx.fmt == "json" or not isinstance(namespaces, list) or not namespaces:
|
|
215
|
+
output(data, ctx.fmt)
|
|
216
|
+
return
|
|
217
|
+
width = max(len(str(n.get("name", ""))) for n in namespaces)
|
|
218
|
+
lines = [
|
|
219
|
+
f"{str(n.get('name','')):<{width}} {str(n.get('count','')):>4} "
|
|
220
|
+
f"{n.get('description','')}"
|
|
221
|
+
for n in namespaces
|
|
222
|
+
]
|
|
223
|
+
lines += [
|
|
224
|
+
"",
|
|
225
|
+
"List one: deepcell ref <namespace>",
|
|
226
|
+
"Read one: deepcell ref <namespace>/<name>",
|
|
227
|
+
"Resolve: deepcell ref <typed-id> e.g. rule:R2",
|
|
228
|
+
"Search: deepcell ref search <text>",
|
|
229
|
+
]
|
|
230
|
+
print_plain("\n".join(lines))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _list_entries(ctx: Ctx, namespace: str) -> None:
|
|
234
|
+
data = ctx.client.get(f"/ref/{namespace}")
|
|
235
|
+
entries = data.get("entries", []) if isinstance(data, dict) else data
|
|
236
|
+
if ctx.fmt == "json" or not isinstance(entries, list) or not entries:
|
|
237
|
+
output(data, ctx.fmt)
|
|
238
|
+
return
|
|
239
|
+
width = max(len(str(e.get("name", ""))) for e in entries)
|
|
240
|
+
lines = [
|
|
241
|
+
f"{str(e.get('name','')):<{width}} {e.get('summary','')}" for e in entries
|
|
242
|
+
]
|
|
243
|
+
lines += ["", f"Read one: deepcell ref {namespace}/<name>"]
|
|
244
|
+
print_plain("\n".join(lines))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _search(ctx: Ctx, text: str, limit: int) -> None:
|
|
248
|
+
"""Search every surface at once and return typed ids.
|
|
249
|
+
|
|
250
|
+
Results say what *kind* of thing was found, so a search for "sensitivity"
|
|
251
|
+
that turns up an op kind, a guide topic and a rule can be told apart
|
|
252
|
+
without a second call to work out which command reads which.
|
|
253
|
+
"""
|
|
254
|
+
if not text:
|
|
255
|
+
print_plain("Usage: deepcell ref search <text>")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
data = ctx.client.get("/ref/search", params={"q": text, "limit": limit})
|
|
259
|
+
results = data.get("results", []) if isinstance(data, dict) else data
|
|
260
|
+
|
|
261
|
+
if ctx.fmt == "json":
|
|
262
|
+
output(data, ctx.fmt)
|
|
263
|
+
return
|
|
264
|
+
if not isinstance(results, list) or not results:
|
|
265
|
+
print_plain(f"No matches for '{text}'.")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
width = max(len(str(r.get("id", ""))) for r in results)
|
|
269
|
+
lines: list[str] = []
|
|
270
|
+
for r in results:
|
|
271
|
+
lines.append(f"{str(r.get('id','')):<{width}} {r.get('summary','')}")
|
|
272
|
+
# A snippet means the summary above did NOT contain the search term —
|
|
273
|
+
# the match is deeper in the entry. Printing it is what stops such a
|
|
274
|
+
# row reading as a mismatch: without it the reader sees a summary with
|
|
275
|
+
# none of their words in it and concludes the search is broken.
|
|
276
|
+
if r.get("snippet"):
|
|
277
|
+
lines.append(f"{'':<{width}} ↳ {r['snippet']}")
|
|
278
|
+
lines += ["", "Read one: deepcell ref <id>"]
|
|
279
|
+
print_plain("\n".join(lines))
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Raw-XML string replacement in a workspace file.
|
|
2
|
+
|
|
3
|
+
Split out of ``edit`` because the two do not share an exit-1 sense. ``edit``
|
|
4
|
+
applies a batch of value changes and reports which rows landed, so a failure is
|
|
5
|
+
**partially applied**. ``replace`` writes the file *first* and validates the
|
|
6
|
+
result *after*, so a failure means the replacement is already on disk and the
|
|
7
|
+
document is invalid — ``written-but-invalid``. ``cli/src/deepcell_cli/
|
|
8
|
+
surface.py`` maps exit senses over command paths, so one command carrying two
|
|
9
|
+
senses was inexpressible; two commands is.
|
|
10
|
+
|
|
11
|
+
``edit --replace`` stays as a deprecated alias (it calls :func:`do_replace`
|
|
12
|
+
directly) so existing scripts and agent transcripts keep working.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from deepcell_cli.commands._batch_input import read_batch_payload
|
|
22
|
+
from deepcell_cli.commands._write_opts import WriteCommand, write_message
|
|
23
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
24
|
+
from deepcell_cli.output import (
|
|
25
|
+
echo_success,
|
|
26
|
+
echo_validation,
|
|
27
|
+
echo_warning,
|
|
28
|
+
output_mutation,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command("replace", cls=WriteCommand)
|
|
33
|
+
@click.argument("filename")
|
|
34
|
+
@click.argument("old_string", required=False)
|
|
35
|
+
@click.argument("new_string", required=False)
|
|
36
|
+
@click.option("--replace-all", is_flag=True, help="Replace every occurrence instead of requiring a unique match.")
|
|
37
|
+
@click.option(
|
|
38
|
+
"--batch",
|
|
39
|
+
"batch_file",
|
|
40
|
+
help='Replacement as a JSON object {"old_string": "...", "new_string": "..."} '
|
|
41
|
+
"— a file path, '-' for stdin, or inline JSON. Use this for multiline XML.",
|
|
42
|
+
)
|
|
43
|
+
@click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
|
|
44
|
+
@click.option("--workspace", "workspace_slug", help="Override active workspace.")
|
|
45
|
+
@pass_ctx
|
|
46
|
+
def replace(
|
|
47
|
+
ctx: Ctx,
|
|
48
|
+
filename: str,
|
|
49
|
+
old_string: str | None,
|
|
50
|
+
new_string: str | None,
|
|
51
|
+
replace_all: bool,
|
|
52
|
+
batch_file: str | None,
|
|
53
|
+
revision: str | None,
|
|
54
|
+
workspace_slug: str | None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Replace raw XML text in a file — the last-resort editor.
|
|
57
|
+
|
|
58
|
+
\b
|
|
59
|
+
Prefer a typed command where one exists:
|
|
60
|
+
Values `deepcell edit FILE ITEM CONTEXT VALUE`
|
|
61
|
+
Structure / formulas `deepcell defs add-calc / add-item / ...`
|
|
62
|
+
Raw XML (this command) sections with no typed op (Sources, ...)
|
|
63
|
+
|
|
64
|
+
\b
|
|
65
|
+
Exit 1 here does NOT mean nothing happened. The file is written first and
|
|
66
|
+
validated second, so exit 1 means the replacement IS already in the file
|
|
67
|
+
and the document is now invalid — read the errors and fix forward.
|
|
68
|
+
|
|
69
|
+
\b
|
|
70
|
+
Matching is tiered, server-side: exact → whitespace-tolerant →
|
|
71
|
+
canonicalization-aware (the server rewrites XML on every write, so an
|
|
72
|
+
old_string composed from what you previously *wrote* may no longer be the
|
|
73
|
+
stored spelling). A non-exact match is reported, because the bytes it
|
|
74
|
+
rewrote are not the bytes you passed.
|
|
75
|
+
|
|
76
|
+
\b
|
|
77
|
+
Examples:
|
|
78
|
+
deepcell replace model.deepcell "<Old>text</Old>" "<New>text</New>"
|
|
79
|
+
deepcell replace model.deepcell -m "fix tax rate" OLD NEW
|
|
80
|
+
deepcell replace model.deepcell --replace-all "old" "new"
|
|
81
|
+
echo '{"old_string":"...","new_string":"..."}' | deepcell replace model.deepcell --batch -
|
|
82
|
+
|
|
83
|
+
\b
|
|
84
|
+
A literal that starts with '-' must come after '--':
|
|
85
|
+
deepcell replace model.deepcell -- "-166667" "-200000"
|
|
86
|
+
"""
|
|
87
|
+
slug = workspace_slug or ctx.require_workspace()
|
|
88
|
+
title, rationale = write_message()
|
|
89
|
+
do_replace(
|
|
90
|
+
ctx, slug, filename, old_string, new_string, batch_file, replace_all,
|
|
91
|
+
revision, title, rationale,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Implementation — shared with the deprecated `edit --replace` alias
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def format_replace_conflict(payload: dict) -> str:
|
|
101
|
+
"""Render the structured 409 detail from the :replace endpoint."""
|
|
102
|
+
reason = payload.get("reason", "conflict")
|
|
103
|
+
lines: list[str] = []
|
|
104
|
+
if reason == "not_found":
|
|
105
|
+
lines.append("old_string not found in file.")
|
|
106
|
+
nearest = payload.get("nearest_match")
|
|
107
|
+
if nearest:
|
|
108
|
+
nearest_lines = payload.get("nearest_lines") or []
|
|
109
|
+
where = (
|
|
110
|
+
f" (lines {nearest_lines[0]}-{nearest_lines[1]})"
|
|
111
|
+
if len(nearest_lines) == 2
|
|
112
|
+
else ""
|
|
113
|
+
)
|
|
114
|
+
lines.append(f"Nearest match in file{where}:")
|
|
115
|
+
lines.append(nearest)
|
|
116
|
+
elif reason == "ambiguous":
|
|
117
|
+
lines.append(f"old_string found {payload.get('count', '?')} times.")
|
|
118
|
+
spelling = payload.get("matched_spelling")
|
|
119
|
+
if spelling and spelling != payload.get("old_string"):
|
|
120
|
+
lines.append("Matched spelling in the file (what --replace-all would rewrite):")
|
|
121
|
+
lines.append(spelling)
|
|
122
|
+
elif reason == "stale_revision":
|
|
123
|
+
current = payload.get("current_revision")
|
|
124
|
+
lines.append(
|
|
125
|
+
f"File changed since your revision (current: {current})."
|
|
126
|
+
if current
|
|
127
|
+
else "File changed since your revision."
|
|
128
|
+
)
|
|
129
|
+
hint = payload.get("hint")
|
|
130
|
+
if hint:
|
|
131
|
+
lines.append(f"Hint: {hint}")
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def do_replace(
|
|
136
|
+
ctx: Ctx,
|
|
137
|
+
slug: str,
|
|
138
|
+
filename: str,
|
|
139
|
+
old_string_arg: str | None,
|
|
140
|
+
new_string_arg: str | None,
|
|
141
|
+
batch_file: str | None,
|
|
142
|
+
replace_all: bool,
|
|
143
|
+
revision: str | None,
|
|
144
|
+
title: str | None = None,
|
|
145
|
+
rationale: str | None = None,
|
|
146
|
+
*,
|
|
147
|
+
usage: str = "deepcell replace FILE OLD NEW",
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Execute string-replacement editing on a workspace file (server-side).
|
|
150
|
+
|
|
151
|
+
Matching happens on the server (POST /files:replace, filename in the
|
|
152
|
+
body) so it can canonicalize old_string through the same normalization
|
|
153
|
+
pipeline the write path uses — a stale-but-semantically-current
|
|
154
|
+
old_string still matches. Against an older server without that route the
|
|
155
|
+
command falls back to the legacy client-side match-and-upload flow.
|
|
156
|
+
|
|
157
|
+
``usage`` names the invocation form in argument errors, so the deprecated
|
|
158
|
+
``edit --replace`` alias still teaches its own spelling.
|
|
159
|
+
"""
|
|
160
|
+
from deepcell_cli.errors import APIError
|
|
161
|
+
|
|
162
|
+
# Parse old_string / new_string
|
|
163
|
+
if batch_file:
|
|
164
|
+
raw = read_batch_payload(batch_file)
|
|
165
|
+
try:
|
|
166
|
+
payload = json.loads(raw)
|
|
167
|
+
except json.JSONDecodeError as exc:
|
|
168
|
+
raise click.UsageError(f"Batch JSON does not parse — {exc}") from exc
|
|
169
|
+
if not isinstance(payload, dict) or "old_string" not in payload or "new_string" not in payload:
|
|
170
|
+
raise click.UsageError(
|
|
171
|
+
'Replace --batch JSON must be {"old_string": "...", "new_string": "..."}.'
|
|
172
|
+
)
|
|
173
|
+
old_string: str = payload["old_string"]
|
|
174
|
+
new_string: str = payload["new_string"]
|
|
175
|
+
elif old_string_arg is not None and new_string_arg is not None:
|
|
176
|
+
old_string = old_string_arg
|
|
177
|
+
new_string = new_string_arg
|
|
178
|
+
else:
|
|
179
|
+
batch_form = usage.rsplit(" OLD NEW", 1)[0]
|
|
180
|
+
raise click.UsageError(
|
|
181
|
+
f"Replace requires two positional args: {usage}\n"
|
|
182
|
+
"or JSON via stdin: echo '{\"old_string\":\"...\",\"new_string\":\"...\"}' "
|
|
183
|
+
f"| {batch_form} --batch -"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
body: dict = {
|
|
187
|
+
"filename": filename,
|
|
188
|
+
"old_string": old_string,
|
|
189
|
+
"new_string": new_string,
|
|
190
|
+
"replace_all": replace_all,
|
|
191
|
+
}
|
|
192
|
+
if revision:
|
|
193
|
+
body["expected_revision"] = revision
|
|
194
|
+
if title:
|
|
195
|
+
body["title"] = title
|
|
196
|
+
if rationale:
|
|
197
|
+
body["rationale"] = rationale
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
result = ctx.client.post(f"/workspaces/{slug}/files:replace", json=body)
|
|
201
|
+
except APIError as exc:
|
|
202
|
+
if exc.status_code == 404 and exc.detail == "Not Found":
|
|
203
|
+
# The framework-default 404 means the files:replace route does not
|
|
204
|
+
# exist (older server) — a missing *file* gets a specific message.
|
|
205
|
+
echo_warning(
|
|
206
|
+
"Server predates server-side replace — falling back to "
|
|
207
|
+
"client-side matching (upgrade the server for "
|
|
208
|
+
"canonicalization-aware matching)."
|
|
209
|
+
)
|
|
210
|
+
do_replace_legacy(
|
|
211
|
+
ctx, slug, filename, old_string, new_string, replace_all,
|
|
212
|
+
title, rationale, revision,
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
if exc.status_code == 409 and isinstance(exc.payload, dict):
|
|
216
|
+
raise click.ClickException(format_replace_conflict(exc.payload)) from exc
|
|
217
|
+
raise
|
|
218
|
+
|
|
219
|
+
output_mutation(result, ctx.fmt, plain_key="commit_sha")
|
|
220
|
+
|
|
221
|
+
errors = echo_validation(result)
|
|
222
|
+
|
|
223
|
+
if isinstance(result, dict):
|
|
224
|
+
tier = result.get("match_tier")
|
|
225
|
+
if tier and tier != "exact":
|
|
226
|
+
echo_warning(
|
|
227
|
+
f"old_string matched via the {tier} tier — the bytes rewritten "
|
|
228
|
+
"in the file differ from the old_string you passed"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
replaced = result.get("replacements", 1) if isinstance(result, dict) else 1
|
|
232
|
+
if errors:
|
|
233
|
+
echo_warning(
|
|
234
|
+
f"{replaced} replacement(s) applied but the document now has "
|
|
235
|
+
f"{len(errors)} validation error(s) — fix the errors above"
|
|
236
|
+
)
|
|
237
|
+
raise click.exceptions.Exit(1)
|
|
238
|
+
echo_success(f"{replaced} replacement(s) applied")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def do_replace_legacy(
|
|
242
|
+
ctx: Ctx,
|
|
243
|
+
slug: str,
|
|
244
|
+
filename: str,
|
|
245
|
+
old_string: str,
|
|
246
|
+
new_string: str,
|
|
247
|
+
replace_all: bool,
|
|
248
|
+
title: str | None,
|
|
249
|
+
rationale: str | None,
|
|
250
|
+
revision: str | None = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Client-side match-and-upload flow for servers without files:replace.
|
|
253
|
+
|
|
254
|
+
Exact + whitespace-tolerant matching only (no canonicalization tier —
|
|
255
|
+
that needs the server's normalization pipeline).
|
|
256
|
+
|
|
257
|
+
This is a read-modify-write across two round trips, so it carries the
|
|
258
|
+
lost-update window the server-side route does not: the revision of the
|
|
259
|
+
bytes it matched against rides back as ``expected_revision``, and the
|
|
260
|
+
caller's own ``--revision`` (if any) takes precedence over it.
|
|
261
|
+
"""
|
|
262
|
+
from deepcell_cli.xml_replace import (
|
|
263
|
+
find_whitespace_tolerant_match,
|
|
264
|
+
normalize_new_string_indentation,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
data, read_revision = ctx.client.get_with_revision(
|
|
268
|
+
f"/workspaces/{slug}/files/{filename}"
|
|
269
|
+
)
|
|
270
|
+
if isinstance(data, dict) and "content" in data:
|
|
271
|
+
content: str = data["content"]
|
|
272
|
+
else:
|
|
273
|
+
raise click.ClickException(f"Could not read file content for '{filename}'.")
|
|
274
|
+
|
|
275
|
+
count = content.count(old_string)
|
|
276
|
+
if count == 1:
|
|
277
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
278
|
+
elif count > 1:
|
|
279
|
+
if replace_all:
|
|
280
|
+
new_content = content.replace(old_string, new_string)
|
|
281
|
+
else:
|
|
282
|
+
raise click.ClickException(
|
|
283
|
+
f"old_string found {count} times. Use --replace-all to replace all occurrences."
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
actual_old = find_whitespace_tolerant_match(content, old_string)
|
|
287
|
+
if actual_old is None:
|
|
288
|
+
raise click.ClickException(
|
|
289
|
+
"old_string not found in file (exact and whitespace-tolerant search)."
|
|
290
|
+
)
|
|
291
|
+
adjusted_new = normalize_new_string_indentation(actual_old, old_string, new_string)
|
|
292
|
+
new_content = content.replace(actual_old, adjusted_new, 1)
|
|
293
|
+
|
|
294
|
+
from deepcell_cli.errors import APIError
|
|
295
|
+
from deepcell_cli.revision import raise_if_stale, write_body
|
|
296
|
+
|
|
297
|
+
# `commit_message` as well as the pair, on this path only. This runs when
|
|
298
|
+
# the server is old enough to lack `files:replace`, which means it is also
|
|
299
|
+
# old enough to lack `title`/`rationale` on the write body — and pydantic
|
|
300
|
+
# drops an unknown field silently, so the reason would vanish with no
|
|
301
|
+
# error. A server that understands both prefers the pair and keeps this as
|
|
302
|
+
# a `Summary` trailer, so sending it costs nothing there.
|
|
303
|
+
body = write_body(
|
|
304
|
+
new_content,
|
|
305
|
+
revision or read_revision,
|
|
306
|
+
title=title,
|
|
307
|
+
rationale=rationale,
|
|
308
|
+
commit_message=rationale or title,
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
result = ctx.client.post(f"/workspaces/{slug}/files/{filename}", json=body)
|
|
312
|
+
except APIError as exc:
|
|
313
|
+
raise_if_stale(exc, filename=filename)
|
|
314
|
+
raise
|
|
315
|
+
output_mutation(result, ctx.fmt, plain_key="commit_sha")
|
|
316
|
+
|
|
317
|
+
errors = echo_validation(result)
|
|
318
|
+
|
|
319
|
+
replaced = count if (count > 1 and replace_all) else 1
|
|
320
|
+
if errors:
|
|
321
|
+
echo_warning(
|
|
322
|
+
f"{replaced} replacement(s) applied but the document now has "
|
|
323
|
+
f"{len(errors)} validation error(s) — fix the errors above"
|
|
324
|
+
)
|
|
325
|
+
raise click.exceptions.Exit(1)
|
|
326
|
+
echo_success(f"{replaced} replacement(s) applied")
|