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,445 @@
|
|
|
1
|
+
"""Version control commands: log, diff, restore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from deepcell_cli.errors import APIError
|
|
8
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
9
|
+
from deepcell_cli.output import (
|
|
10
|
+
echo_error, echo_info, echo_success, echo_warning, output, output_mutation, print_plain,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _handle_invalid_revision(e: APIError) -> None:
|
|
15
|
+
"""Format invalid-revision API errors with structured output."""
|
|
16
|
+
detail = e.detail
|
|
17
|
+
# Parse the structured error from the API
|
|
18
|
+
# Format: "Invalid revision 'REV'. ERROR. Recent revisions: r1, r2. Use 'deepcell log'..."
|
|
19
|
+
if "Invalid revision" in detail:
|
|
20
|
+
# Extract parts for structured display
|
|
21
|
+
parts = detail.split(". ")
|
|
22
|
+
echo_error(parts[0]) # "Invalid revision 'REV'"
|
|
23
|
+
if len(parts) > 1:
|
|
24
|
+
echo_info(f" {parts[1]}") # error detail
|
|
25
|
+
for part in parts[2:]:
|
|
26
|
+
if part.startswith("Recent revisions:"):
|
|
27
|
+
echo_info(f" {part}")
|
|
28
|
+
elif "deepcell log" in part:
|
|
29
|
+
echo_info(f" Hint: {part}")
|
|
30
|
+
raise SystemExit(1)
|
|
31
|
+
raise e
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.command()
|
|
35
|
+
@click.argument("filename_pos", metavar="[FILENAME]", required=False)
|
|
36
|
+
@click.option("--file", "filename", default=None, help="Filter by filename.")
|
|
37
|
+
@click.option("-n", "--limit", default=20, help="Number of entries to show.")
|
|
38
|
+
@click.option(
|
|
39
|
+
"--after",
|
|
40
|
+
"after_sha",
|
|
41
|
+
default=None,
|
|
42
|
+
help="Pagination cursor: start AFTER this commit sha. Pass the last sha "
|
|
43
|
+
"of the previous page to read the next one.",
|
|
44
|
+
)
|
|
45
|
+
@pass_ctx
|
|
46
|
+
def log(
|
|
47
|
+
ctx: Ctx,
|
|
48
|
+
filename_pos: str | None,
|
|
49
|
+
filename: str | None,
|
|
50
|
+
limit: int,
|
|
51
|
+
after_sha: str | None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Show version history for the workspace (like git log).
|
|
54
|
+
|
|
55
|
+
The filename filter can be positional (`log model.deepcell`, the form
|
|
56
|
+
every read command uses) or `--file` — flag-only was a measurable trap
|
|
57
|
+
(eval U5).
|
|
58
|
+
|
|
59
|
+
\b
|
|
60
|
+
Paging back through a long history:
|
|
61
|
+
deepcell log -n 20 # newest 20
|
|
62
|
+
deepcell log -n 20 --after <sha> # the 20 after that page's last sha
|
|
63
|
+
"""
|
|
64
|
+
if filename_pos and filename:
|
|
65
|
+
raise click.UsageError("Pass FILENAME positionally or via --file, not both.")
|
|
66
|
+
filename = filename_pos or filename
|
|
67
|
+
slug = ctx.require_workspace()
|
|
68
|
+
params: dict = {"max_count": limit}
|
|
69
|
+
if filename:
|
|
70
|
+
params["filename"] = filename
|
|
71
|
+
# Key matches the `after_sha` query param on GET /workspaces/{slug}/versions.
|
|
72
|
+
if after_sha:
|
|
73
|
+
params["after_sha"] = after_sha
|
|
74
|
+
data = ctx.client.get(f"/workspaces/{slug}/versions", params=params)
|
|
75
|
+
output(data, ctx.fmt)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@click.command()
|
|
79
|
+
@click.argument("revision_a")
|
|
80
|
+
@click.argument("revision_b", required=False)
|
|
81
|
+
@click.option("--file", "filename", default=None, help="Filter diff by filename.")
|
|
82
|
+
@pass_ctx
|
|
83
|
+
def diff(ctx: Ctx, revision_a: str, revision_b: str | None, filename: str | None) -> None:
|
|
84
|
+
"""Show diff between two revisions (like git diff).
|
|
85
|
+
|
|
86
|
+
\b
|
|
87
|
+
deepcell diff abc123 # compare revision to HEAD
|
|
88
|
+
deepcell diff abc123 def456 # compare two revisions
|
|
89
|
+
"""
|
|
90
|
+
slug = ctx.require_workspace()
|
|
91
|
+
params: dict = {"from_rev": revision_a}
|
|
92
|
+
if revision_b:
|
|
93
|
+
params["to_rev"] = revision_b
|
|
94
|
+
if filename:
|
|
95
|
+
params["filename"] = filename
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
data = ctx.client.get(f"/workspaces/{slug}/versions/diff", params=params)
|
|
99
|
+
except APIError as e:
|
|
100
|
+
if e.status_code == 400:
|
|
101
|
+
_handle_invalid_revision(e)
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
# If the response is a string (unified diff), print as plain text
|
|
105
|
+
if isinstance(data, str):
|
|
106
|
+
print_plain(data)
|
|
107
|
+
elif isinstance(data, list) and ctx.fmt == "plain":
|
|
108
|
+
_print_diff_plain(data)
|
|
109
|
+
else:
|
|
110
|
+
output(data, ctx.fmt)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _print_diff_plain(entries: list[dict]) -> None:
|
|
114
|
+
"""Format semantic diff results for plain-text display."""
|
|
115
|
+
if not entries:
|
|
116
|
+
echo_info("No differences found.")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
for entry in entries:
|
|
120
|
+
file_path = entry.get("file_path", "")
|
|
121
|
+
change_type = entry.get("change_type", "modified")
|
|
122
|
+
click.echo(click.style(f"--- {file_path} ({change_type})", bold=True), err=False)
|
|
123
|
+
|
|
124
|
+
sd = entry.get("semantic_diff")
|
|
125
|
+
if sd:
|
|
126
|
+
_print_semantic_diff(sd)
|
|
127
|
+
elif entry.get("patch"):
|
|
128
|
+
print_plain(entry["patch"])
|
|
129
|
+
click.echo("") # blank line between files
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Section keys emitted by the backend's compute_semantic_diff
|
|
133
|
+
# (backend/src/services/git_service.py). Contract: the shared fixture
|
|
134
|
+
# cli/tests/fixtures/semantic_diff_contract.json is generated from the real
|
|
135
|
+
# backend output and both suites test against it — see
|
|
136
|
+
# cli/tests/test_commands/test_version.py and
|
|
137
|
+
# backend/tests/test_semantic_diff_contract.py. If the backend adds a
|
|
138
|
+
# section, regenerate the fixture and add a renderer branch here.
|
|
139
|
+
SEMANTIC_DIFF_SECTIONS = (
|
|
140
|
+
"items_added",
|
|
141
|
+
"items_removed",
|
|
142
|
+
"contexts_added",
|
|
143
|
+
"contexts_removed",
|
|
144
|
+
"contexts_changed",
|
|
145
|
+
"value_changes",
|
|
146
|
+
"formula_changes",
|
|
147
|
+
"format_changes",
|
|
148
|
+
"presentation_changes",
|
|
149
|
+
"deck_changes",
|
|
150
|
+
"document_changes",
|
|
151
|
+
"reasoning_changes",
|
|
152
|
+
"source_changes",
|
|
153
|
+
"scenario_changes",
|
|
154
|
+
"status_changes",
|
|
155
|
+
"metadata_changes",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sd_label(entry: dict, id_key: str, name_key: str) -> str:
|
|
160
|
+
"""Format `id (name)`, collapsing to `id` when the name adds nothing."""
|
|
161
|
+
eid = str(entry.get(id_key, "?"))
|
|
162
|
+
name = entry.get(name_key)
|
|
163
|
+
if name and str(name) != eid:
|
|
164
|
+
return f"{eid} ({name})"
|
|
165
|
+
return eid
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _print_added_removed(sd: dict, added_key: str, removed_key: str, noun: str, name_key: str) -> None:
|
|
169
|
+
"""Render a pair of added/removed sections (items or contexts)."""
|
|
170
|
+
added = sd.get(added_key, [])
|
|
171
|
+
if added:
|
|
172
|
+
click.echo(f" {noun} Added:")
|
|
173
|
+
for e in added:
|
|
174
|
+
click.echo(click.style(f" + {_sd_label(e, 'id', name_key)}", fg="green"))
|
|
175
|
+
removed = sd.get(removed_key, [])
|
|
176
|
+
if removed:
|
|
177
|
+
click.echo(f" {noun} Removed:")
|
|
178
|
+
for e in removed:
|
|
179
|
+
click.echo(click.style(f" - {_sd_label(e, 'id', name_key)}", fg="red"))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _echo_old_new(prefix: str, old, new) -> None:
|
|
183
|
+
"""Render `prefix: old -> new` with the old value red and new green."""
|
|
184
|
+
click.echo(f"{prefix}: ", nl=False)
|
|
185
|
+
click.echo(click.style(str(old) if old is not None else "(none)", fg="red"), nl=False)
|
|
186
|
+
click.echo(" -> ", nl=False)
|
|
187
|
+
click.echo(click.style(str(new) if new is not None else "(none)", fg="green"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _print_semantic_diff(sd: dict) -> None:
|
|
191
|
+
"""Format a semantic diff section with colored output.
|
|
192
|
+
|
|
193
|
+
Renders the sections emitted by the backend's compute_semantic_diff
|
|
194
|
+
(see SEMANTIC_DIFF_SECTIONS above for the shared-fixture contract).
|
|
195
|
+
"""
|
|
196
|
+
_print_added_removed(sd, "items_added", "items_removed", "Items", "name")
|
|
197
|
+
_print_added_removed(sd, "contexts_added", "contexts_removed", "Contexts", "label")
|
|
198
|
+
|
|
199
|
+
ctx_changed = sd.get("contexts_changed", [])
|
|
200
|
+
if ctx_changed:
|
|
201
|
+
click.echo(" Contexts Changed:")
|
|
202
|
+
for cc in ctx_changed:
|
|
203
|
+
prefix = f" ~ {cc.get('id', '?')} {cc.get('field', '?')}"
|
|
204
|
+
_echo_old_new(prefix, cc.get("old"), cc.get("new"))
|
|
205
|
+
|
|
206
|
+
# Split the values section by cause. A run that edits three assumptions and
|
|
207
|
+
# recalculates fifty-seven cells printed sixty indistinguishable lines, and
|
|
208
|
+
# the three worth reading were somewhere in the middle. `derived` is the
|
|
209
|
+
# backend's answer (a CalcDef governs the cell); an entry without the key
|
|
210
|
+
# predates it and is shown as an edit, which is the safe way to be wrong —
|
|
211
|
+
# it over-reports what to read, never under-reports.
|
|
212
|
+
value_changes = sd.get("value_changes", [])
|
|
213
|
+
if value_changes:
|
|
214
|
+
edited = [cv for cv in value_changes if not cv.get("derived")]
|
|
215
|
+
recalculated = [cv for cv in value_changes if cv.get("derived")]
|
|
216
|
+
|
|
217
|
+
def _echo_cells(heading: str, entries: list) -> None:
|
|
218
|
+
if not entries:
|
|
219
|
+
return
|
|
220
|
+
click.echo(heading)
|
|
221
|
+
for cv in entries:
|
|
222
|
+
item = cv.get("item", "?")
|
|
223
|
+
ctx = cv.get("context", "?")
|
|
224
|
+
status = cv.get("status")
|
|
225
|
+
cell = f"{item}[{ctx}]" + (f" ({status})" if status else "")
|
|
226
|
+
_echo_old_new(f" {cell}", cv.get("old"), cv.get("new"))
|
|
227
|
+
|
|
228
|
+
_echo_cells(" Changed Values:", edited)
|
|
229
|
+
_echo_cells(" Recalculated:", recalculated)
|
|
230
|
+
|
|
231
|
+
formula_changes = sd.get("formula_changes", [])
|
|
232
|
+
if formula_changes:
|
|
233
|
+
click.echo(" Changed Formulas:")
|
|
234
|
+
for fc in formula_changes:
|
|
235
|
+
calc_id = fc.get("id", "?")
|
|
236
|
+
item = fc.get("item")
|
|
237
|
+
label = f"{calc_id} ({item})" if item and item != calc_id else str(calc_id)
|
|
238
|
+
click.echo(f" {label}:")
|
|
239
|
+
old_f = fc.get("old_formula")
|
|
240
|
+
new_f = fc.get("new_formula")
|
|
241
|
+
if old_f is not None:
|
|
242
|
+
click.echo(click.style(f" - {old_f}", fg="red"))
|
|
243
|
+
if new_f is not None:
|
|
244
|
+
click.echo(click.style(f" + {new_f}", fg="green"))
|
|
245
|
+
|
|
246
|
+
format_changes = sd.get("format_changes", [])
|
|
247
|
+
if format_changes:
|
|
248
|
+
click.echo(" Format Changes:")
|
|
249
|
+
for fc in format_changes:
|
|
250
|
+
details = fc.get("details")
|
|
251
|
+
line = f" ~ {fc.get('format_id', '?')}: {fc.get('change_type', '?')}"
|
|
252
|
+
if details:
|
|
253
|
+
line += f" — {details}"
|
|
254
|
+
click.echo(line)
|
|
255
|
+
|
|
256
|
+
pres_changes = sd.get("presentation_changes", [])
|
|
257
|
+
if pres_changes:
|
|
258
|
+
click.echo(" Presentation Changes:")
|
|
259
|
+
for pc in pres_changes:
|
|
260
|
+
change_type = pc.get("change_type", "?")
|
|
261
|
+
target = pc.get("block_id") or pc.get("sheet_id") or "?"
|
|
262
|
+
name = pc.get("name")
|
|
263
|
+
line = f" ~ {change_type}: {target}"
|
|
264
|
+
if name and name != target:
|
|
265
|
+
line += f" ({name})"
|
|
266
|
+
if pc.get("field"):
|
|
267
|
+
click.echo(line, nl=False)
|
|
268
|
+
_echo_old_new(f" {pc['field']}", pc.get("old"), pc.get("new"))
|
|
269
|
+
else:
|
|
270
|
+
click.echo(line)
|
|
271
|
+
|
|
272
|
+
for section_key, id_key, noun in (
|
|
273
|
+
("scenario_changes", "scenario_id", "Scenario"),
|
|
274
|
+
("status_changes", "status_id", "Status"),
|
|
275
|
+
):
|
|
276
|
+
changes = sd.get(section_key, [])
|
|
277
|
+
if changes:
|
|
278
|
+
click.echo(f" {noun} Changes:")
|
|
279
|
+
for sc in changes:
|
|
280
|
+
sign = "+" if sc.get("change_type") == "added" else "-"
|
|
281
|
+
color = "green" if sign == "+" else "red"
|
|
282
|
+
click.echo(click.style(f" {sign} {_sd_label(sc, id_key, 'label')}", fg=color))
|
|
283
|
+
|
|
284
|
+
_print_deliverable_changes(sd)
|
|
285
|
+
|
|
286
|
+
meta_changes = sd.get("metadata_changes", [])
|
|
287
|
+
if meta_changes:
|
|
288
|
+
click.echo(" Metadata Changes:")
|
|
289
|
+
for mc in meta_changes:
|
|
290
|
+
_echo_old_new(f" ~ {mc.get('field', '?')}", mc.get("old"), mc.get("new"))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
#: Which id a deliverable entry is addressed by, most specific first. A slide
|
|
294
|
+
#: entry carries both a deck id and a slide id; the slide is what changed.
|
|
295
|
+
_DELIVERABLE_ID_KEYS = (
|
|
296
|
+
"binding_id",
|
|
297
|
+
"slide_id",
|
|
298
|
+
"block_id",
|
|
299
|
+
"claim_id",
|
|
300
|
+
"assumption_id",
|
|
301
|
+
"evidence_id",
|
|
302
|
+
"argument_id",
|
|
303
|
+
"source_id",
|
|
304
|
+
"doc_id",
|
|
305
|
+
"deck_id",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
#: Entries that REMOVE something, rendered red with a `-`. `binding_removed` is
|
|
309
|
+
#: on this list and is the one worth spotting: it is Detach, the single action
|
|
310
|
+
#: that costs a number its provenance.
|
|
311
|
+
_DELIVERABLE_REMOVALS = frozenset(
|
|
312
|
+
{
|
|
313
|
+
"deck_removed",
|
|
314
|
+
"slide_removed",
|
|
315
|
+
"document_removed",
|
|
316
|
+
"block_removed",
|
|
317
|
+
"claim_removed",
|
|
318
|
+
"binding_removed",
|
|
319
|
+
# The reasoning graph. `argument_removed` is on this list for the same
|
|
320
|
+
# reason `binding_removed` is: it is a retraction. An edge that stops
|
|
321
|
+
# existing is a conclusion that stops being held up by something, and
|
|
322
|
+
# nothing else in the diff says so.
|
|
323
|
+
"assumption_removed",
|
|
324
|
+
"evidence_removed",
|
|
325
|
+
"argument_removed",
|
|
326
|
+
"source_removed",
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
_DELIVERABLE_ADDITIONS = frozenset(
|
|
331
|
+
{
|
|
332
|
+
"deck_added",
|
|
333
|
+
"slide_added",
|
|
334
|
+
"document_added",
|
|
335
|
+
"block_added",
|
|
336
|
+
"claim_added",
|
|
337
|
+
"binding_added",
|
|
338
|
+
"assumption_added",
|
|
339
|
+
"evidence_added",
|
|
340
|
+
"argument_added",
|
|
341
|
+
"source_added",
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _print_deliverable_changes(sd: dict) -> None:
|
|
347
|
+
"""Render the deck, document and reasoning sections.
|
|
348
|
+
|
|
349
|
+
One renderer for three sections because the entries share a shape: a
|
|
350
|
+
`change_type`, an id, and an optional `old`/`new` pair. Three near-identical
|
|
351
|
+
branches would drift the first time one of them gained a field.
|
|
352
|
+
"""
|
|
353
|
+
for section_key, noun in (
|
|
354
|
+
("deck_changes", "Deck"),
|
|
355
|
+
("document_changes", "Document"),
|
|
356
|
+
("reasoning_changes", "Reasoning"),
|
|
357
|
+
("source_changes", "Source"),
|
|
358
|
+
):
|
|
359
|
+
changes = sd.get(section_key, [])
|
|
360
|
+
if not changes:
|
|
361
|
+
continue
|
|
362
|
+
click.echo(f" {noun} Changes:")
|
|
363
|
+
for entry in changes:
|
|
364
|
+
change_type = entry.get("change_type", "?")
|
|
365
|
+
target = next(
|
|
366
|
+
(str(entry[key]) for key in _DELIVERABLE_ID_KEYS if entry.get(key)),
|
|
367
|
+
"?",
|
|
368
|
+
)
|
|
369
|
+
# `@id` on an <Argument> is optional, so an edge entry may carry no
|
|
370
|
+
# id at all. Its address is the (from, rel, to) triple, and printing
|
|
371
|
+
# "?" for the one element kind that routinely has no id would make
|
|
372
|
+
# every retracted edge unreadable.
|
|
373
|
+
if target == "?" and entry.get("rel"):
|
|
374
|
+
target = (
|
|
375
|
+
f"{entry.get('from', '?')} -{entry['rel']}-> {entry.get('to', '?')}"
|
|
376
|
+
)
|
|
377
|
+
if change_type in _DELIVERABLE_ADDITIONS:
|
|
378
|
+
sign, color = "+", "green"
|
|
379
|
+
elif change_type in _DELIVERABLE_REMOVALS:
|
|
380
|
+
sign, color = "-", "red"
|
|
381
|
+
else:
|
|
382
|
+
sign, color = "~", None
|
|
383
|
+
name = entry.get("name") or entry.get("label")
|
|
384
|
+
line = f" {sign} {change_type}: {target}"
|
|
385
|
+
if name and str(name) != target:
|
|
386
|
+
line += f" ({name})"
|
|
387
|
+
if entry.get("field"):
|
|
388
|
+
line += f" {entry['field']}"
|
|
389
|
+
styled = click.style(line, fg=color) if color else line
|
|
390
|
+
# `old`/`new` carry the whole content of a rebind, so print them
|
|
391
|
+
# where they exist rather than leaving "binding_modified: b3" as
|
|
392
|
+
# the entire account of a number that changed what it points at.
|
|
393
|
+
if entry.get("old") is not None or entry.get("new") is not None:
|
|
394
|
+
click.echo(styled, nl=False)
|
|
395
|
+
_echo_old_new("", entry.get("old"), entry.get("new"))
|
|
396
|
+
else:
|
|
397
|
+
click.echo(styled)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@click.command()
|
|
401
|
+
@click.argument("revision")
|
|
402
|
+
@click.option("--file", "filename", default=None, help="Restore specific file only.")
|
|
403
|
+
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt.")
|
|
404
|
+
@pass_ctx
|
|
405
|
+
def restore(ctx: Ctx, revision: str, filename: str | None, yes: bool) -> None:
|
|
406
|
+
"""Restore the workspace to a previous revision (like git checkout).
|
|
407
|
+
|
|
408
|
+
Restores every file unless --file names one. A full restore rebuilds the
|
|
409
|
+
tree from the target revision alone, so **files added after it are
|
|
410
|
+
deleted** — the response lists any it removed. Restoring is itself a new
|
|
411
|
+
commit, so nothing is lost for good: restore a newer revision to undo it.
|
|
412
|
+
|
|
413
|
+
This moves the workspace on the server. Local folders are untouched until
|
|
414
|
+
you `deepcell pull`.
|
|
415
|
+
"""
|
|
416
|
+
if not yes:
|
|
417
|
+
click.confirm("Are you sure you want to restore this revision?", abort=True)
|
|
418
|
+
slug = ctx.require_workspace()
|
|
419
|
+
body: dict = {"target_version": revision}
|
|
420
|
+
if filename:
|
|
421
|
+
body["filename"] = filename
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
data = ctx.client.post(f"/workspaces/{slug}/versions/restore", json=body)
|
|
425
|
+
except APIError as e:
|
|
426
|
+
if e.status_code == 400:
|
|
427
|
+
_handle_invalid_revision(e)
|
|
428
|
+
raise
|
|
429
|
+
|
|
430
|
+
output_mutation(data, ctx.fmt, plain_key="sha")
|
|
431
|
+
echo_success(f"Restored to revision {revision[:8]}")
|
|
432
|
+
|
|
433
|
+
# A whole-workspace restore rebuilds the tree from the target alone, so
|
|
434
|
+
# every file added after it is deleted. Nothing said so — the command
|
|
435
|
+
# printed a sha and exited 0 while the files were gone.
|
|
436
|
+
deleted = (data or {}).get("files_deleted") or []
|
|
437
|
+
if deleted:
|
|
438
|
+
echo_warning(
|
|
439
|
+
f"{len(deleted)} file(s) added after {revision[:8]} were DELETED: "
|
|
440
|
+
+ ", ".join(str(f) for f in deleted)
|
|
441
|
+
)
|
|
442
|
+
echo_warning(
|
|
443
|
+
"Recover them from a newer revision (`deepcell log`, then "
|
|
444
|
+
"`deepcell download <file> --revision <sha>`)."
|
|
445
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""``deepcell viewer`` — print the browser URL to open a workspace file.
|
|
2
|
+
|
|
3
|
+
A local URL constructor (no API call of its own — resolving the active
|
|
4
|
+
workspace may still hit ``GET /workspaces``): signed-in sessions get the
|
|
5
|
+
authenticated workbench route (``/agent?assistantId=deepcell&workspaceSlug=…
|
|
6
|
+
&file=…``) where they operate with full auth — editing, agent chat, history.
|
|
7
|
+
Anonymous sessions can't sign into the browser as the CLI's device-keyed
|
|
8
|
+
identity, so the command points them at ``share create`` / ``login`` instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from urllib.parse import urlencode
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from deepcell_cli.config import frontend_base_url, is_anonymous_session
|
|
18
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def workbench_url(slug: str, filename: str) -> str:
|
|
22
|
+
"""The authenticated workbench deep link for *filename* in *slug*."""
|
|
23
|
+
query = urlencode(
|
|
24
|
+
{"assistantId": "deepcell", "workspaceSlug": slug, "file": filename}
|
|
25
|
+
)
|
|
26
|
+
return f"{frontend_base_url()}/agent?{query}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.command()
|
|
30
|
+
@click.argument("filename")
|
|
31
|
+
@click.option("--workspace", "workspace_slug", help="Override active workspace.")
|
|
32
|
+
@click.option("--open", "launch", is_flag=True, help="Open the URL in the default browser.")
|
|
33
|
+
@pass_ctx
|
|
34
|
+
def viewer(ctx: Ctx, filename: str, workspace_slug: str | None, launch: bool) -> None:
|
|
35
|
+
"""Print the browser URL to open FILENAME in the web workbench.
|
|
36
|
+
|
|
37
|
+
Requires a signed-in session — the workbench authenticates as your
|
|
38
|
+
account. Otherwise create a public view link with
|
|
39
|
+
`deepcell share create <file>` instead, or run `deepcell login` first.
|
|
40
|
+
"""
|
|
41
|
+
# Check anonymity before resolving the workspace: require_workspace()
|
|
42
|
+
# would otherwise auto-provision a scratch workspace for an anon session
|
|
43
|
+
# only for this command to error out anyway.
|
|
44
|
+
if is_anonymous_session():
|
|
45
|
+
raise click.ClickException(
|
|
46
|
+
"The browser can't sign in as this CLI session.\n"
|
|
47
|
+
f"To view the file, create a view link: deepcell share create {filename}\n"
|
|
48
|
+
"For the full workbench (edit, agent chat), run `deepcell login` first."
|
|
49
|
+
)
|
|
50
|
+
slug = workspace_slug or ctx.require_workspace()
|
|
51
|
+
url = workbench_url(slug, filename)
|
|
52
|
+
click.echo(url)
|
|
53
|
+
if launch:
|
|
54
|
+
click.launch(url)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Project commands: list, use, info, create.
|
|
2
|
+
|
|
3
|
+
The Click-visible name is ``project``; ``workspace`` stays as a hidden
|
|
4
|
+
alias registered in ``main``. The MODULE keeps its name so it still
|
|
5
|
+
mirrors ``backend/jingwei_api/routers/workspaces.py`` 1:1, and so do the
|
|
6
|
+
function names and the ``active_workspace`` config key — renaming that
|
|
7
|
+
key would silently drop every existing user's active selection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from deepcell_cli.config import get_active_workspace, set_active_workspace
|
|
15
|
+
from deepcell_cli.errors import EmailVerificationRequired
|
|
16
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
17
|
+
from deepcell_cli.output import echo_error, echo_info, echo_success, output, output_mutation
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group("project")
|
|
21
|
+
def project() -> None:
|
|
22
|
+
"""Manage projects.
|
|
23
|
+
|
|
24
|
+
A project is a container for .deepcell files, version history, and
|
|
25
|
+
variants. Most commands require an active project — set one with
|
|
26
|
+
`deepcell project use <slug>`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@project.command("list")
|
|
31
|
+
@pass_ctx
|
|
32
|
+
def workspace_list(ctx: Ctx) -> None:
|
|
33
|
+
"""List projects you belong to."""
|
|
34
|
+
data = ctx.client.get("/workspaces")
|
|
35
|
+
active = get_active_workspace()
|
|
36
|
+
for ws in data:
|
|
37
|
+
if isinstance(ws, dict):
|
|
38
|
+
ws["active"] = ws.get("slug") == active
|
|
39
|
+
output(data, ctx.fmt)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@project.command("use")
|
|
43
|
+
@click.argument("slug")
|
|
44
|
+
@pass_ctx
|
|
45
|
+
def workspace_use(ctx: Ctx, slug: str) -> None:
|
|
46
|
+
"""Set the active project for subsequent commands."""
|
|
47
|
+
# Validate the project exists and user has access
|
|
48
|
+
ctx.client.get(f"/workspaces/{slug}")
|
|
49
|
+
set_active_workspace(slug)
|
|
50
|
+
echo_success(f"Active project set to '{slug}'")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@project.command("info")
|
|
54
|
+
@click.argument("slug", required=False)
|
|
55
|
+
@pass_ctx
|
|
56
|
+
def workspace_info(ctx: Ctx, slug: str | None) -> None:
|
|
57
|
+
"""Show project details."""
|
|
58
|
+
slug = slug or ctx.require_workspace()
|
|
59
|
+
data = ctx.client.get(f"/workspaces/{slug}")
|
|
60
|
+
output(data, ctx.fmt)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@project.command("create")
|
|
64
|
+
@click.argument("name")
|
|
65
|
+
@click.option(
|
|
66
|
+
"--slug",
|
|
67
|
+
help=(
|
|
68
|
+
"URL-friendly slug. Derived from the name by the server, with a short "
|
|
69
|
+
"random suffix, if omitted — slugs are unique across all projects."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
@click.option("--description", default="", help="Project description.")
|
|
73
|
+
@pass_ctx
|
|
74
|
+
def workspace_create(ctx: Ctx, name: str, slug: str | None, description: str) -> None:
|
|
75
|
+
"""Create a new project."""
|
|
76
|
+
# Omitted `--slug` is sent as nothing at all, and the server derives one.
|
|
77
|
+
# It is the only party that can: slugs are globally unique while
|
|
78
|
+
# `GET /workspaces` returns only your own, so anything derived here is a
|
|
79
|
+
# guess against a namespace this process cannot read. Deriving locally also
|
|
80
|
+
# could not name a CJK project at all — stripping non-ASCII left "", under
|
|
81
|
+
# the server's 3-character floor, so the command refused a name the server
|
|
82
|
+
# is perfectly able to slug.
|
|
83
|
+
body: dict = {"name": name, "description": description}
|
|
84
|
+
if slug:
|
|
85
|
+
body["slug"] = slug
|
|
86
|
+
try:
|
|
87
|
+
data = ctx.client.post("/workspaces", json=body)
|
|
88
|
+
except EmailVerificationRequired:
|
|
89
|
+
echo_info("Email verification is required to create projects.")
|
|
90
|
+
from deepcell_cli.commands.auth import _run_verification_flow
|
|
91
|
+
|
|
92
|
+
if _run_verification_flow(ctx):
|
|
93
|
+
# Retry after successful verification
|
|
94
|
+
data = ctx.client.post("/workspaces", json=body)
|
|
95
|
+
else:
|
|
96
|
+
echo_error("Email verification failed. Cannot create project.")
|
|
97
|
+
raise SystemExit(1)
|
|
98
|
+
output_mutation(data, ctx.fmt, plain_key="slug")
|
|
99
|
+
echo_success(f"Project '{data.get('slug', name)}' created")
|
|
100
|
+
|
|
101
|
+
|