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