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,386 @@
1
+ """File commands: ls, cat, write, rm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from deepcell_cli.commands._version_display import echo_version_history
10
+ from deepcell_cli.commands._write_opts import WriteCommand, write_message
11
+ from deepcell_cli.commands.viewer import workbench_url
12
+ from deepcell_cli.context import Ctx, pass_ctx
13
+ from deepcell_cli.output import (
14
+ echo_success,
15
+ echo_validation,
16
+ echo_warning,
17
+ output,
18
+ output_mutation,
19
+ print_plain,
20
+ )
21
+
22
+
23
+ @click.command()
24
+ @pass_ctx
25
+ def ls(ctx: Ctx) -> None:
26
+ """List files in the active workspace."""
27
+ slug = ctx.require_workspace()
28
+ data = ctx.client.get(f"/workspaces/{slug}/files")
29
+ output(data, ctx.fmt)
30
+
31
+
32
+ @click.command()
33
+ @click.argument("filename")
34
+ @click.option("--revision", default=None, help="Show file at specific revision.")
35
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
36
+ @pass_ctx
37
+ def cat(ctx: Ctx, filename: str, revision: str | None, workspace_slug: str | None) -> None:
38
+ """Print file content (XML) to stdout.
39
+
40
+ To modify definition sections, use `deepcell replace` for targeted
41
+ string replacement. See `deepcell guide present/layout` for layout details.
42
+ """
43
+ slug = workspace_slug or ctx.require_workspace()
44
+ params: dict = {}
45
+ if revision:
46
+ params["revision"] = revision
47
+ data, read_revision = ctx.client.get_with_revision(
48
+ f"/workspaces/{slug}/files/{filename}", params=params or None
49
+ )
50
+ if isinstance(data, dict) and read_revision:
51
+ data.setdefault("revision", read_revision)
52
+ # The endpoint returns the file content — could be JSON with 'content' key or raw
53
+ if isinstance(data, dict) and "content" in data:
54
+ print_plain(data["content"])
55
+ else:
56
+ output(data, ctx.fmt)
57
+ # Surface the revision the bytes were read at (it travels as a response
58
+ # header, so it is otherwise invisible). It is the token that
59
+ # `deepcell write --revision` / `deepcell replace --revision` take, and
60
+ # therefore the only way a scripted read-modify-write can be safe against a
61
+ # concurrent editor. On **stderr**, like every other footer here: stdout
62
+ # carries the raw XML and must stay pipeable (#570).
63
+ if read_revision:
64
+ click.echo(f"Revision: {read_revision}", err=True)
65
+ if isinstance(data, dict):
66
+ echo_version_history(data)
67
+
68
+
69
+ @click.command(cls=WriteCommand)
70
+ @click.argument("filename")
71
+ @click.option("--stdin", "from_stdin", is_flag=True, help="Read content from stdin.")
72
+ @click.option("--file", "from_file", type=click.Path(exists=True), help="Read content from a local file.")
73
+ @click.option("--content", "from_content", default=None, help="Pass file content inline (useful for MCP/programmatic access).")
74
+ @click.option("--content-base64", "from_b64", default=None, help="Pass file content as a base64-encoded string.")
75
+ @click.option(
76
+ "--revision",
77
+ default=None,
78
+ help="Expected revision SHA for optimistic locking — the `Revision:` line "
79
+ "`deepcell cat` prints on stderr. The write is refused with a conflict if "
80
+ "FILENAME changed since (a change to a different file is not a conflict).",
81
+ )
82
+ @click.option(
83
+ "--share/--no-share",
84
+ "want_share",
85
+ default=None,
86
+ help="Print a view-only share link for FILENAME as well. On by default "
87
+ "when you are not signed in, where it is the only URL that opens in a "
88
+ "browser; off by default when signed in, since the workbench link already "
89
+ "works. "
90
+ "An existing live link for the file is reused rather than duplicated.",
91
+ )
92
+ @pass_ctx
93
+ def write(ctx: Ctx, filename: str, from_stdin: bool, from_file: str | None, from_content: str | None, from_b64: str | None, revision: str | None, want_share: bool | None) -> None:
94
+ """Create a file, or replace an existing one wholesale.
95
+
96
+ This writes the whole document: whatever is in the workspace under
97
+ FILENAME is replaced by the content you pass, not merged with it. To
98
+ change part of a document, edit it in place — `deepcell edit` for values,
99
+ `deepcell defs` for structure — which is almost always what you want.
100
+ The overwrite is a commit, so `deepcell log` and `deepcell restore` can
101
+ recover the previous content.
102
+
103
+ Provide content via --stdin (pipe), --file, --content, or --content-base64.
104
+
105
+ .deepcell files may include optional definition sections — run
106
+ `deepcell guide generate/values`, `deepcell guide revise/scenarios`,
107
+ or `deepcell ref format` for XML format details.
108
+
109
+ On success the command prints a browser URL for the file — the workbench
110
+ link when you are signed in, a public view link when you are not, so there
111
+ is always one to hand over. Relay it on the FIRST write and again when you
112
+ sign off, so the user follows the work rather than waiting for a report,
113
+ and still leaves with a link.
114
+
115
+ Both links are live: the page follows the file while you keep writing, so
116
+ the user watches the model take shape either way. The workbench offers each
117
+ change for review because its reader may be editing; a view-only share
118
+ simply updates. See `deepcell guide present/deliver`.
119
+ """
120
+ slug = ctx.require_workspace()
121
+
122
+ if from_content is not None:
123
+ content = from_content
124
+ elif from_b64:
125
+ import base64
126
+ try:
127
+ content = base64.b64decode(from_b64).decode("utf-8")
128
+ except Exception as exc:
129
+ raise click.UsageError(f"Invalid base64 content: {exc}")
130
+ elif from_stdin:
131
+ content = sys.stdin.read()
132
+ elif from_file:
133
+ with open(from_file) as fh:
134
+ content = fh.read()
135
+ else:
136
+ raise click.UsageError("Provide content with --stdin, --file, --content, or --content-base64.")
137
+
138
+ from deepcell_cli.errors import APIError
139
+ from deepcell_cli.revision import raise_if_stale, write_body
140
+
141
+ title, rationale = write_message()
142
+ body = write_body(
143
+ content, revision, title=title or None, rationale=rationale or None
144
+ )
145
+
146
+ try:
147
+ data = ctx.client.post(f"/workspaces/{slug}/files/{filename}", json=body)
148
+ except APIError as exc:
149
+ raise_if_stale(exc, filename=filename)
150
+ raise
151
+ # Resolve the browser link before emitting anything, so structured
152
+ # consumers (`-f json`, MCP) receive the URL as a field of the mutation
153
+ # payload rather than having to scrape it off stderr.
154
+ links = _browser_links(ctx, slug, filename, want_share=want_share)
155
+ if isinstance(data, dict):
156
+ for key, url in links.items():
157
+ data.setdefault(key, url)
158
+
159
+ errors = echo_validation(data)
160
+ output_mutation(data, ctx.fmt, plain_key="commit_sha")
161
+ if errors:
162
+ # The server persists invalid .deepcell content on purpose (computed
163
+ # values stay fresh; errors ride along in `validation`), so the save
164
+ # is real — but it must not read as a clean success or exit 0.
165
+ echo_warning(
166
+ f"File '{filename}' saved with {len(errors)} validation error(s) — "
167
+ "fix the errors above and write again"
168
+ )
169
+ else:
170
+ echo_success(f"File '{filename}' saved")
171
+ _echo_browser_links(filename, links)
172
+ if isinstance(data, dict):
173
+ echo_version_history(data)
174
+ if errors:
175
+ click.get_current_context().exit(1)
176
+
177
+
178
+ def _browser_links(
179
+ ctx: Ctx, slug: str, filename: str, *, want_share: bool | None
180
+ ) -> dict[str, str]:
181
+ """Browser URLs to hand back for a just-written FILENAME.
182
+
183
+ A URL, not a command to run next: the reason to offer the link this early
184
+ is that the page follows the file while the agent is still working (#1553),
185
+ on both surfaces — the share page polls `/share/{token}/head`. A follow-up command they have to be told to run arrives
186
+ too late to be that — and a write that hands back nothing leaves an
187
+ anonymous session with no way into the browser at all.
188
+
189
+ So there is always a link, and which one follows from the session. Signed
190
+ in, it is the workbench URL: built locally, free, and the owner's own
191
+ editing surface. Anonymous, it is a view-only share link, because the
192
+ browser cannot sign in as the CLI's device-keyed identity — that costs a
193
+ request, and it is worth one.
194
+
195
+ ``want_share`` overrides the choice in both directions: ``True`` adds a
196
+ share link to a signed-in write (the hand-off case), ``False`` suppresses
197
+ minting entirely for a caller that does not want a capability token created
198
+ on its behalf.
199
+ """
200
+ from deepcell_cli.config import is_anonymous_session
201
+
202
+ links: dict[str, str] = {}
203
+ is_document = filename.endswith(".deepcell")
204
+ anonymous = is_anonymous_session()
205
+ if is_document and not anonymous:
206
+ links["browser_url"] = workbench_url(slug, filename)
207
+ share_wanted = (
208
+ want_share if want_share is not None else (is_document and anonymous)
209
+ )
210
+ if share_wanted:
211
+ share = _ensure_view_share(ctx, slug, filename)
212
+ if share:
213
+ links["share_url"] = share[0]
214
+ # The raw timestamp, not the date shown to a human: a machine
215
+ # reading this wants to compare it, and the day it lapses on
216
+ # depends on the reader's timezone.
217
+ if share[1]:
218
+ links["share_expires_at"] = share[1]
219
+ return links
220
+
221
+
222
+ def _ensure_view_share(
223
+ ctx: Ctx, slug: str, filename: str
224
+ ) -> tuple[str, str | None] | None:
225
+ """This file's view link and its expiry — the one it has, or a new one."""
226
+ return _live_view_share(ctx, slug, filename) or _create_view_share(
227
+ ctx, slug, filename
228
+ )
229
+
230
+
231
+ def _live_view_share(
232
+ ctx: Ctx, slug: str, filename: str
233
+ ) -> tuple[str, str | None] | None:
234
+ """A usable view link this file already has, if any.
235
+
236
+ A write is not a new thing to share. It is a new revision of the thing the
237
+ link already points at, and a link tracks the file rather than the revision
238
+ — so minting one per write would leave a document trailing a pile of live
239
+ capability tokens, each independently leakable and each needing its own
240
+ revoke. Look before creating.
241
+
242
+ A failure to look is not a failure to write: fall through to minting rather
243
+ than reporting an error for a link the caller did not ask about by name.
244
+ """
245
+ from deepcell_cli.config import frontend_base_url
246
+ from deepcell_cli.errors import APIError
247
+
248
+ try:
249
+ rows = ctx.client.get(
250
+ f"/workspaces/{slug}/shares", params={"filename": filename}
251
+ )
252
+ except APIError:
253
+ return None
254
+ if not isinstance(rows, list):
255
+ return None
256
+ for row in rows:
257
+ if not isinstance(row, dict) or row.get("permission") != "view":
258
+ continue
259
+ # A password-protected link is not one the holder can simply open, so
260
+ # it does not answer "give the user something to look at".
261
+ if row.get("has_password") or not row.get("share_token"):
262
+ continue
263
+ expires_at = row.get("expires_at")
264
+ if not _link_still_live(expires_at):
265
+ continue
266
+ url = f"{frontend_base_url()}/share/{row['share_token']}"
267
+ return url, str(expires_at) if expires_at else None
268
+ return None
269
+
270
+
271
+ def _link_still_live(expires_at: object) -> bool:
272
+ """Whether a listed link has not lapsed.
273
+
274
+ The listing filters on ``is_active``, which revocation clears — expiry does
275
+ not, so an expired row still comes back. Handing out a dead URL is worse
276
+ than minting a fresh link, and an expiry that cannot be read is not evidence
277
+ the link works, so an unparseable value counts as lapsed.
278
+ """
279
+ if not expires_at:
280
+ return True
281
+ from datetime import datetime, timezone
282
+
283
+ try:
284
+ parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
285
+ except ValueError:
286
+ return False
287
+ if parsed.tzinfo is None:
288
+ parsed = parsed.replace(tzinfo=timezone.utc)
289
+ return parsed > datetime.now(timezone.utc)
290
+
291
+
292
+ def _create_view_share(
293
+ ctx: Ctx, slug: str, filename: str
294
+ ) -> tuple[str, str | None] | None:
295
+ """Mint a view-only share link, or warn and return None.
296
+
297
+ The write already succeeded and is not undone by a failed share call, so a
298
+ sharing error is reported and stepped over rather than raised — losing the
299
+ "file saved" result to a link problem would be the worse outcome.
300
+ """
301
+ from deepcell_cli.config import frontend_base_url
302
+ from deepcell_cli.errors import APIError
303
+
304
+ try:
305
+ row = ctx.client.post(
306
+ f"/workspaces/{slug}/shares",
307
+ json={"filename": filename, "permission": "view"},
308
+ )
309
+ except APIError as exc:
310
+ echo_warning(
311
+ f"File saved, but the share link could not be created: {exc}. "
312
+ f"Retry with `deepcell share create {filename}`."
313
+ )
314
+ return None
315
+ token = row.get("share_token") if isinstance(row, dict) else None
316
+ if not token:
317
+ return None
318
+ expires_at = row.get("expires_at")
319
+ return (
320
+ f"{frontend_base_url()}/share/{token}",
321
+ str(expires_at) if expires_at else None,
322
+ )
323
+
324
+
325
+ def _echo_browser_links(filename: str, links: dict[str, str]) -> None:
326
+ """Print the links on stderr, keeping stdout to the commit sha."""
327
+ if links.get("browser_url"):
328
+ click.echo(f"Open it in the browser: {links['browser_url']}", err=True)
329
+ if links.get("share_url"):
330
+ # Say when it dies, on the line that hands it over. Every link expires,
331
+ # and a URL presented with no lifetime reads like a permanent one — the
332
+ # holder finds out it lapsed by clicking a dead page.
333
+ expires = _expiry_date(links.get("share_expires_at"))
334
+ label = f"Share link (view, expires {expires})" if expires else "Share link (view)"
335
+ click.echo(f"{label}: {links['share_url']}", err=True)
336
+ if not links and filename.endswith(".deepcell"):
337
+ # Only reachable with `--no-share` on a session with no workbench URL:
338
+ # the caller refused the one link that session can offer.
339
+ click.echo(
340
+ f"No link printed (--no-share). `deepcell share create {filename}` "
341
+ "makes one.",
342
+ err=True,
343
+ )
344
+
345
+
346
+ def _expiry_date(expires_at: object) -> str | None:
347
+ """The calendar day a link lapses on, for a human reading one line.
348
+
349
+ The full timestamp goes to structured consumers; a person handing a link to
350
+ a colleague needs the day, and a date is what they will repeat out loud.
351
+ An unreadable value prints nothing rather than a guess.
352
+ """
353
+ if not expires_at:
354
+ return None
355
+ from datetime import datetime
356
+
357
+ try:
358
+ parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
359
+ except ValueError:
360
+ return None
361
+ return parsed.date().isoformat()
362
+
363
+
364
+ @click.command()
365
+ @click.argument("filename")
366
+ @click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt.")
367
+ @pass_ctx
368
+ def rm(ctx: Ctx, filename: str, yes: bool) -> None:
369
+ """Delete a file from the workspace.
370
+
371
+ The deletion is a commit, so the file is recoverable: `deepcell log` finds
372
+ the revision before it and `deepcell restore <revision>` or `deepcell
373
+ download <file> --revision <revision>` brings it back.
374
+ """
375
+ if not yes:
376
+ click.confirm(f"Are you sure you want to delete '{filename}'?", abort=True)
377
+ slug = ctx.require_workspace()
378
+ resp = ctx.client.delete(f"/workspaces/{slug}/files/{filename}")
379
+ # Show the deletion commit so it can be referenced / restored past later.
380
+ # (delete returns the raw response; 204-no-body servers have no sha.)
381
+ try:
382
+ data = resp.json() if getattr(resp, "content", b"") else {}
383
+ except ValueError:
384
+ data = {}
385
+ output_mutation(data, ctx.fmt, plain_key="commit_sha")
386
+ echo_success(f"File '{filename}' deleted")
@@ -0,0 +1,90 @@
1
+ """``deepcell grep`` — client-side search within .deepcell files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import click
8
+
9
+ from deepcell_cli.context import Ctx, pass_ctx
10
+ from deepcell_cli.output import echo_warning, output
11
+
12
+
13
+ @click.command()
14
+ @click.argument("pattern")
15
+ @click.option("--file", "filename", default=None, help="Search within a specific file.")
16
+ @click.option("-i", "--ignore-case", is_flag=True, help="Case-insensitive search.")
17
+ @pass_ctx
18
+ def grep(ctx: Ctx, pattern: str, filename: str | None, ignore_case: bool) -> None:
19
+ """Search for items/values matching a pattern in .deepcell files.
20
+
21
+ Matches against item ids, labels, and values. Each file is downloaded in
22
+ full to search it, so scope with --file when you know where to look.
23
+ """
24
+ slug = ctx.require_workspace()
25
+
26
+ # Determine files to search
27
+ if filename:
28
+ # An explicit --file must fail loudly on a bad name — pre-fix both a
29
+ # typo'd filename and a non-.deepcell file printed "(empty)" exit 0,
30
+ # indistinguishable from "no match".
31
+ if not filename.endswith(".deepcell"):
32
+ raise click.UsageError(
33
+ f"'{filename}' is not a .deepcell file — grep searches "
34
+ ".deepcell XML only."
35
+ )
36
+ filenames = [filename]
37
+ else:
38
+ files_data = ctx.client.get(f"/workspaces/{slug}/files")
39
+ if isinstance(files_data, list):
40
+ filenames = [
41
+ f.get("filename", f.get("name", ""))
42
+ for f in files_data
43
+ if isinstance(f, dict)
44
+ ]
45
+ else:
46
+ filenames = []
47
+
48
+ flags = re.IGNORECASE if ignore_case else 0
49
+ try:
50
+ regex = re.compile(pattern, flags)
51
+ except re.error as e:
52
+ raise click.ClickException(f"Invalid regex: {e}")
53
+
54
+ matches: list[dict] = []
55
+
56
+ for fname in filenames:
57
+ if not fname.endswith(".deepcell"):
58
+ continue
59
+
60
+ try:
61
+ file_data = ctx.client.get(f"/workspaces/{slug}/files/{fname}")
62
+ xml = file_data.get("content", "") if isinstance(file_data, dict) else ""
63
+ except Exception as exc:
64
+ if filename:
65
+ # Explicit --file: the fetch failure IS the answer.
66
+ raise click.ClickException(f"Could not read '{fname}': {exc}")
67
+ # Workspace sweep: keep searching the rest, but say what was skipped
68
+ # — a silent skip reads as "searched everything, no match".
69
+ echo_warning(f"skipped '{fname}' (could not read: {exc})")
70
+ continue
71
+
72
+ if not xml:
73
+ continue
74
+
75
+ for lineno, line in enumerate(xml.splitlines(), 1):
76
+ if regex.search(line):
77
+ matches.append(
78
+ {
79
+ "file": fname,
80
+ "line": lineno,
81
+ "content": line.strip(),
82
+ }
83
+ )
84
+
85
+ if not matches and ctx.fmt == "plain":
86
+ # The generic empty-list placeholder "(empty)" scans as "empty file
87
+ # list"; say what actually happened.
88
+ click.echo("(no matches)")
89
+ return
90
+ output(matches, ctx.fmt)