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,133 @@
|
|
|
1
|
+
"""Click command class that accepts negative numbers in positional slots.
|
|
2
|
+
|
|
3
|
+
Click reads any token starting with ``-`` as an option, so a negative literal
|
|
4
|
+
written where a VALUE belongs dies before the command body runs::
|
|
5
|
+
|
|
6
|
+
$ deepcell edit model.deepcell ResearchSpend W17 -166667
|
|
7
|
+
Error: No such option: -1
|
|
8
|
+
|
|
9
|
+
POSIX's ``--`` escape works, but nothing in ``--help`` mentions it, and
|
|
10
|
+
financial models are full of negative literals — costs, ledger-signed spend,
|
|
11
|
+
downward adjustments (issue #1290).
|
|
12
|
+
|
|
13
|
+
The rescue is deliberately narrow. ``ignore_unknown_options`` would fix the
|
|
14
|
+
parse by turning *every* unknown option into a positional, so a typo like
|
|
15
|
+
``edit FILE ITEM CTX --scenarioo`` would be written into the document as the
|
|
16
|
+
string ``"--scenarioo"``. Instead we let Click parse normally and only step in
|
|
17
|
+
*after* it has already failed with ``NoSuchOption``, re-parsing with the
|
|
18
|
+
offending token moved behind ``--``. The success path is untouched, and a
|
|
19
|
+
mistyped option — which never looks like a number — still fails loudly with
|
|
20
|
+
Click's own "Did you mean …?" suggestion.
|
|
21
|
+
|
|
22
|
+
Only for commands whose negative-capable positional is **last**: escaped
|
|
23
|
+
tokens are appended after the other arguments, which preserves positional
|
|
24
|
+
order only if nothing positional follows them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
import click
|
|
32
|
+
|
|
33
|
+
# Applied to ``NoSuchOption.option_name``, which Click truncates to the first
|
|
34
|
+
# two characters for short-option-style tokens (``-166667`` is reported as
|
|
35
|
+
# ``-1``, ``-.5`` as ``-.``). Anything else — ``--scenarioo``, ``-x`` — is a
|
|
36
|
+
# real unknown option and must keep failing.
|
|
37
|
+
_UNKNOWN_LOOKS_NUMERIC = re.compile(r"^-[\d.]")
|
|
38
|
+
|
|
39
|
+
# Applied to the full token to decide what may be rescued: an optionally
|
|
40
|
+
# thousands-separated decimal, with optional exponent or trailing percent.
|
|
41
|
+
_NEGATIVE_NUMBER = re.compile(
|
|
42
|
+
r"""^-
|
|
43
|
+
(?:\d[\d,]*)? # integer part, e.g. 1,234
|
|
44
|
+
(?:\.\d+)? # fraction, e.g. .50
|
|
45
|
+
(?:[eE][-+]?\d+)? # exponent, e.g. e6
|
|
46
|
+
%?$ # percent literal, e.g. -12%
|
|
47
|
+
""",
|
|
48
|
+
re.VERBOSE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_negative_number(token: str) -> bool:
|
|
53
|
+
return bool(_NEGATIVE_NUMBER.match(token)) and any(c.isdigit() for c in token)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NegativeNumberCommand(click.Command):
|
|
57
|
+
"""A ``click.Command`` that tolerates negative numbers as positionals."""
|
|
58
|
+
|
|
59
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
60
|
+
pending = list(args)
|
|
61
|
+
escaped: list[str] = []
|
|
62
|
+
# Bounded by the number of tokens: each retry escapes exactly one.
|
|
63
|
+
for _ in range(len(pending) + 1):
|
|
64
|
+
try:
|
|
65
|
+
return super().parse_args(ctx, self._compose(pending, escaped))
|
|
66
|
+
except click.NoSuchOption as exc:
|
|
67
|
+
index = self._rescuable_index(pending, exc)
|
|
68
|
+
if index is None:
|
|
69
|
+
raise self._with_hint(ctx, exc) from None
|
|
70
|
+
escaped.append(pending.pop(index))
|
|
71
|
+
return super().parse_args(ctx, self._compose(pending, escaped))
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _compose(pending: list[str], escaped: list[str]) -> list[str]:
|
|
75
|
+
return [*pending, "--", *escaped] if escaped else list(pending)
|
|
76
|
+
|
|
77
|
+
def _rescuable_index(
|
|
78
|
+
self, pending: list[str], exc: click.NoSuchOption
|
|
79
|
+
) -> int | None:
|
|
80
|
+
"""Index of the negative-number token that broke the parse, if any."""
|
|
81
|
+
if not _UNKNOWN_LOOKS_NUMERIC.match(exc.option_name or ""):
|
|
82
|
+
return None
|
|
83
|
+
# The caller already wrote a `--`; a second one would be parsed as a
|
|
84
|
+
# positional value, so leave their argv alone.
|
|
85
|
+
if "--" in pending:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
value_opts = self._value_taking_opts()
|
|
89
|
+
i = 0
|
|
90
|
+
while i < len(pending):
|
|
91
|
+
token = pending[i]
|
|
92
|
+
# Click consumes the token after a value option even when it starts
|
|
93
|
+
# with `-`, so `--scenario -1` is already fine and must not be
|
|
94
|
+
# mistaken for the broken positional.
|
|
95
|
+
if token in value_opts:
|
|
96
|
+
i += 2
|
|
97
|
+
continue
|
|
98
|
+
if _is_negative_number(token):
|
|
99
|
+
return i
|
|
100
|
+
i += 1
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _value_taking_opts(self) -> set[str]:
|
|
104
|
+
opts: set[str] = set()
|
|
105
|
+
for param in self.params:
|
|
106
|
+
if not isinstance(param, click.Option):
|
|
107
|
+
continue
|
|
108
|
+
if param.is_flag or getattr(param, "count", False):
|
|
109
|
+
continue
|
|
110
|
+
opts.update(param.opts)
|
|
111
|
+
opts.update(param.secondary_opts)
|
|
112
|
+
return opts
|
|
113
|
+
|
|
114
|
+
def _with_hint(
|
|
115
|
+
self, ctx: click.Context, exc: click.NoSuchOption
|
|
116
|
+
) -> click.NoSuchOption:
|
|
117
|
+
"""Point at ``--`` for leading-dash values we could not rescue."""
|
|
118
|
+
name = exc.option_name or ""
|
|
119
|
+
if not name.startswith("-") or name.startswith("--"):
|
|
120
|
+
return exc
|
|
121
|
+
# Click reports the first short option it could not resolve, which for
|
|
122
|
+
# `-xyz` is just `-x` — so the hint names the slot, not the token.
|
|
123
|
+
metavar = "VALUE"
|
|
124
|
+
for param in self.params:
|
|
125
|
+
if isinstance(param, click.Argument):
|
|
126
|
+
metavar = param.name.upper()
|
|
127
|
+
exc.message = (
|
|
128
|
+
f"{exc.message}\n\n"
|
|
129
|
+
f"Hint: a literal {metavar} that starts with '-' must come after "
|
|
130
|
+
f"'--':\n"
|
|
131
|
+
f" {ctx.command_path} ... -- {metavar}"
|
|
132
|
+
)
|
|
133
|
+
return exc
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Click command class that catches a swapped FILENAME / ID argument pair.
|
|
2
|
+
|
|
3
|
+
Every `.deepcell`-addressing command puts the file first::
|
|
4
|
+
|
|
5
|
+
deepcell claim history model.deepcell c_thesis
|
|
6
|
+
|
|
7
|
+
Write the two the other way round and the file lookup is what fails, so the
|
|
8
|
+
error talks about the *id*::
|
|
9
|
+
|
|
10
|
+
$ deepcell claim history c_thesis model.deepcell
|
|
11
|
+
Error: File 'c_thesis' not found in workspace '722e186c-…'.
|
|
12
|
+
Available files: ['model.deepcell']
|
|
13
|
+
|
|
14
|
+
Everything in that message is true and none of it names the mistake. The
|
|
15
|
+
guide eval caught a reader doing exactly this (`calc-specificity` task-3,
|
|
16
|
+
trace line 11) after they had just run several commands that take the id
|
|
17
|
+
first in prose; recovering cost them a round trip they should not have paid.
|
|
18
|
+
|
|
19
|
+
The rescue is deliberately narrow, for the same reason `_negative_args`'s is:
|
|
20
|
+
it fires only when the FILENAME slot holds something that is *not* a document
|
|
21
|
+
name **and** a later positional holds something that *is*. That pair is not a
|
|
22
|
+
plausible spelling of any correct invocation — a real file named `c_thesis`
|
|
23
|
+
would still not put `model.deepcell` in an id slot — so there is no shape this
|
|
24
|
+
steals from the success path. Anything else (a genuinely missing file, a typo
|
|
25
|
+
in the id) keeps failing exactly as it did.
|
|
26
|
+
|
|
27
|
+
Sniffing the *extension* rather than asking the API keeps this free: the check
|
|
28
|
+
runs before any network call, which is the point — the whole cost being saved
|
|
29
|
+
is the round trip.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import click
|
|
35
|
+
|
|
36
|
+
#: Suffixes that mark a token as a document name rather than a node id. Kept
|
|
37
|
+
#: to what the workspace actually stores; `.xml` is deliberately absent, since
|
|
38
|
+
#: these commands address workspace `.deepcell` files only.
|
|
39
|
+
_DOCUMENT_SUFFIXES = (".deepcell",)
|
|
40
|
+
|
|
41
|
+
#: The parameter holding the document name. Every command below declares it
|
|
42
|
+
#: with this exact name, and the class asserts that rather than guessing, so a
|
|
43
|
+
#: future command that names it differently fails loudly instead of silently
|
|
44
|
+
#: opting out of the check.
|
|
45
|
+
_FILE_PARAM = "filename"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_document(value: object) -> bool:
|
|
49
|
+
return isinstance(value, str) and value.endswith(_DOCUMENT_SUFFIXES)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FileFirstCommand(click.Command):
|
|
53
|
+
"""Reject a FILENAME / ID pair that is obviously the wrong way round.
|
|
54
|
+
|
|
55
|
+
Applied to commands whose signature is ``FILENAME <ID>``. The check reads
|
|
56
|
+
only positional parameters: an option value that happens to end in
|
|
57
|
+
``.deepcell`` (``--workspace`` never does, but ``--output`` could) is not
|
|
58
|
+
a positional and cannot be the swapped half of this pair.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
62
|
+
rest = super().parse_args(ctx, args)
|
|
63
|
+
self._check_swapped(ctx)
|
|
64
|
+
return rest
|
|
65
|
+
|
|
66
|
+
def _check_swapped(self, ctx: click.Context) -> None:
|
|
67
|
+
params = {p.name: p for p in self.params}
|
|
68
|
+
file_param = params.get(_FILE_PARAM)
|
|
69
|
+
if file_param is None: # pragma: no cover - guarded by the arg-order test
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"{self.name!r} uses FileFirstCommand but declares no "
|
|
72
|
+
f"{_FILE_PARAM!r} argument."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
given_file = ctx.params.get(_FILE_PARAM)
|
|
76
|
+
if _is_document(given_file):
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
for name, param in params.items():
|
|
80
|
+
if name == _FILE_PARAM or not isinstance(param, click.Argument):
|
|
81
|
+
continue
|
|
82
|
+
value = ctx.params.get(name)
|
|
83
|
+
if isinstance(value, (tuple, list)):
|
|
84
|
+
# A variadic id slot: the
|
|
85
|
+
# document can be anywhere in it, so report the one that looks
|
|
86
|
+
# like a document rather than the whole tuple.
|
|
87
|
+
value = next((v for v in value if _is_document(v)), None)
|
|
88
|
+
if not _is_document(value):
|
|
89
|
+
continue
|
|
90
|
+
raise click.UsageError(
|
|
91
|
+
f"{value!r} looks like the document and {given_file!r} looks "
|
|
92
|
+
f"like the {name.upper()} — the arguments appear to be the "
|
|
93
|
+
f"wrong way round.\n"
|
|
94
|
+
f"The document always comes first:\n"
|
|
95
|
+
f" {ctx.command_path} {value} {given_file}",
|
|
96
|
+
ctx=ctx,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FileFirstGroup(click.Group):
|
|
101
|
+
"""Catch a document name handed to a GROUP that has no default action.
|
|
102
|
+
|
|
103
|
+
`claim` and `assumption` are groups: `deepcell claim history f.deepcell
|
|
104
|
+
c_thesis`. But `describe`, `query` and `cat` all take a document directly,
|
|
105
|
+
so `deepcell claim f.deepcell` is the natural analogy to draw — and Click
|
|
106
|
+
answers it by looking for a subcommand named `f.deepcell`::
|
|
107
|
+
|
|
108
|
+
$ deepcell claim model.deepcell
|
|
109
|
+
Error: No such command 'model.deepcell'.
|
|
110
|
+
|
|
111
|
+
True, and it names neither the mistake nor the fix. The reader has to
|
|
112
|
+
already know that `claim` is a group to decode it, which is exactly what
|
|
113
|
+
they did not know.
|
|
114
|
+
|
|
115
|
+
Same narrowness as `FileFirstCommand`: it fires only when the subcommand
|
|
116
|
+
slot holds something ending in `.deepcell`. No real subcommand is spelled
|
|
117
|
+
that way, so this steals nothing from the success path — a genuine typo in
|
|
118
|
+
a subcommand name still gets Click's own error, with its suggestions.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def resolve_command(self, ctx: click.Context, args: list[str]):
|
|
122
|
+
if args and _is_document(args[0]):
|
|
123
|
+
document = args[0]
|
|
124
|
+
raise click.UsageError(
|
|
125
|
+
f"{document!r} is a document, and {self.name!r} is a group of "
|
|
126
|
+
f"subcommands rather than a command that takes one.\n"
|
|
127
|
+
f"The subcommand comes first, then the document:\n"
|
|
128
|
+
+ "\n".join(
|
|
129
|
+
f" {ctx.command_path} {self._usage(name, document)}"
|
|
130
|
+
for name in sorted(self.commands)
|
|
131
|
+
),
|
|
132
|
+
ctx=ctx,
|
|
133
|
+
)
|
|
134
|
+
return super().resolve_command(ctx, args)
|
|
135
|
+
|
|
136
|
+
def _usage(self, name: str, document: str) -> str:
|
|
137
|
+
"""`history model.deepcell CLAIM_ID` — the subcommand's real signature.
|
|
138
|
+
|
|
139
|
+
One canned example with a `[ID]` bolted on would be wrong for whichever
|
|
140
|
+
subcommand does not take one, and the reader has no way to tell which.
|
|
141
|
+
Every subcommand in these groups is one line, so print them all with
|
|
142
|
+
the arguments they actually declare.
|
|
143
|
+
"""
|
|
144
|
+
command = self.commands[name]
|
|
145
|
+
parts = [name]
|
|
146
|
+
for param in command.params:
|
|
147
|
+
if not isinstance(param, click.Argument):
|
|
148
|
+
continue
|
|
149
|
+
if param.name == _FILE_PARAM:
|
|
150
|
+
parts.append(document)
|
|
151
|
+
else:
|
|
152
|
+
parts.append((param.name or "arg").upper())
|
|
153
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Shared helper to display version history in CLI output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def echo_version_history(data: dict[str, Any]) -> None:
|
|
11
|
+
"""Display version history summary if present in API response.
|
|
12
|
+
|
|
13
|
+
The footer is human-convenience metadata, so it is written to **stderr**
|
|
14
|
+
(like ``echo_success`` / ``echo_warning``) — keeping stdout machine-readable.
|
|
15
|
+
Otherwise it contaminates piped output: e.g. ``deepcell cat`` emits raw
|
|
16
|
+
``.deepcell`` XML and the footer would land after ``</DeepCellDocument>``,
|
|
17
|
+
making the captured document invalid XML (issue #570).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
data: API response dict that may contain a 'version_history' key.
|
|
21
|
+
"""
|
|
22
|
+
vh = data.get("version_history")
|
|
23
|
+
if not vh:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
commits = vh.get("commits", [])
|
|
27
|
+
total = vh.get("total", 0)
|
|
28
|
+
showing = vh.get("showing", 0)
|
|
29
|
+
head_sha = vh.get("head_sha", "")[:7] if vh.get("head_sha") else "none"
|
|
30
|
+
|
|
31
|
+
click.echo(f"\nVersion: {head_sha} ({showing} of {total} commits)", err=True)
|
|
32
|
+
|
|
33
|
+
if commits:
|
|
34
|
+
latest = commits[0]
|
|
35
|
+
# Truncate message to first line
|
|
36
|
+
title = latest["message"].split("\n", 1)[0]
|
|
37
|
+
click.echo(
|
|
38
|
+
f'Last change: "{title}" by {latest["author"]}, {latest["timestamp"]}',
|
|
39
|
+
err=True,
|
|
40
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Click command classes that give every document-editing command ``-m``.
|
|
2
|
+
|
|
3
|
+
Every command that edits a `.deepcell` writes a git commit, and the commit
|
|
4
|
+
message is the only surviving record of *why*. The diff already shows what
|
|
5
|
+
moved; nothing reconstructs the reason afterwards.
|
|
6
|
+
|
|
7
|
+
That was true of the CLI long before it was uniform. `edit` took ``-m`` and
|
|
8
|
+
``--title`` from the start; `doc` took ``-m`` but never ``--title``; the 47
|
|
9
|
+
`defs` subcommands took neither until #1717's sibling fix injected ``-m``
|
|
10
|
+
through ``_DefsCommand`` — and mapped it to the commit *subject* rather than
|
|
11
|
+
the body, so the same flag meant two different things depending on which
|
|
12
|
+
family you typed. The `reasoning` family took nothing at all, which is the
|
|
13
|
+
sharpest version of the defect: the one surface whose entire subject matter is
|
|
14
|
+
why the work says what it says was the one surface that could not record it
|
|
15
|
+
(#1717). Its writes fell through to ``Update <filename>`` — the exact string
|
|
16
|
+
the agent's own ``write_file`` override tells the model to avoid.
|
|
17
|
+
|
|
18
|
+
So the rule this module enforces is a rule about *meaning*, not just about
|
|
19
|
+
which flags parse:
|
|
20
|
+
|
|
21
|
+
``-m`` (aliases ``--message`` / ``--rationale``) is always WHY, and lands
|
|
22
|
+
in ``rationale``. ``--title`` is always the commit SUBJECT, and lands in
|
|
23
|
+
``title``. The mechanical summary the server would have written is never
|
|
24
|
+
lost — it survives as a ``Summary`` trailer.
|
|
25
|
+
|
|
26
|
+
Injection rather than declaration, for the reason ``_DefsCommand`` records
|
|
27
|
+
about ``--dry-run``: declared per-command the flag tracks who remembered to
|
|
28
|
+
add it, not what the wire supports. The value is parked in ``ctx.meta`` so the
|
|
29
|
+
~95 callback signatures stay unchanged.
|
|
30
|
+
|
|
31
|
+
Applied per *command* for `doc` and `reasoning`, not per group: both groups
|
|
32
|
+
mix reads and writes (`doc blocks`, `reasoning lint`), and a group-wide
|
|
33
|
+
``command_class`` would hang a meaningless ``-m`` on a command that commits
|
|
34
|
+
nothing. `defs` and `deck` stay group-wide — every command in them is a write.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import click
|
|
40
|
+
|
|
41
|
+
from deepcell_cli.commands._negative_args import NegativeNumberCommand
|
|
42
|
+
from deepcell_cli.commands._swapped_args import FileFirstCommand
|
|
43
|
+
|
|
44
|
+
#: Where the parsed values are parked. ``ctx.meta`` rather than the callback
|
|
45
|
+
#: signature so adding the options to a family does not touch its functions.
|
|
46
|
+
_TITLE_META_KEY = "deepcell.write.title"
|
|
47
|
+
_RATIONALE_META_KEY = "deepcell.write.rationale"
|
|
48
|
+
|
|
49
|
+
#: Every spelling that means "why". The guard below checks ALL of them, not
|
|
50
|
+
#: just the long name: ``_DefsCommand`` checked ``--message`` alone, so a
|
|
51
|
+
#: future ``-m/--mode`` on some subcommand would have slipped past the skip
|
|
52
|
+
#: and raised a duplicate-option error at import time — a failure that lands
|
|
53
|
+
#: on whoever adds the unrelated flag, pointing at this file.
|
|
54
|
+
_RATIONALE_SPELLINGS = ("-m", "--message", "--rationale")
|
|
55
|
+
_TITLE_SPELLINGS = ("--title",)
|
|
56
|
+
|
|
57
|
+
_RATIONALE_HELP = (
|
|
58
|
+
"Why this edit was made. Becomes the commit message body, so the history "
|
|
59
|
+
"reads as the decision rather than the coordinates the diff already "
|
|
60
|
+
"shows. `-m` and `--message` are aliases of `--rationale`."
|
|
61
|
+
)
|
|
62
|
+
_TITLE_HELP = (
|
|
63
|
+
"Short commit subject (e.g. 'Q3 actuals update'). Combined with "
|
|
64
|
+
"--rationale as 'title: rationale'. The mechanical summary the server "
|
|
65
|
+
"would have written is kept as a Summary trailer either way."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WriteCommand(click.Command):
|
|
70
|
+
"""A command that writes a commit — so it takes ``-m`` and ``--title``.
|
|
71
|
+
|
|
72
|
+
A subcommand that declares either option itself keeps its own: `edit`
|
|
73
|
+
spells both out and threads them through its own batch body, and `doc`
|
|
74
|
+
spells ``-m`` out because its callbacks take the value directly.
|
|
75
|
+
Declaring one is opting out of the injection for that flag only; the
|
|
76
|
+
other is still injected.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
80
|
+
super().__init__(*args, **kwargs)
|
|
81
|
+
declared = {
|
|
82
|
+
opt
|
|
83
|
+
for param in self.params
|
|
84
|
+
if isinstance(param, click.Option)
|
|
85
|
+
for opt in param.opts
|
|
86
|
+
}
|
|
87
|
+
#: What this class added, as opposed to what the command spelled out
|
|
88
|
+
#: itself. Only an injected option may be popped in ``invoke``: `doc`
|
|
89
|
+
#: declares an option literally named ``rationale`` and its callbacks
|
|
90
|
+
#: take that keyword, so popping it would be a TypeError on every doc
|
|
91
|
+
#: write. Recorded rather than re-derived, because the two are
|
|
92
|
+
#: indistinguishable from the parameter afterwards.
|
|
93
|
+
self.injected_write_opts: set[str] = set()
|
|
94
|
+
if not declared.intersection(_RATIONALE_SPELLINGS):
|
|
95
|
+
self.params.append(
|
|
96
|
+
click.Option(
|
|
97
|
+
["-m", "--message", "--rationale", "rationale"],
|
|
98
|
+
default=None,
|
|
99
|
+
help=_RATIONALE_HELP,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
self.injected_write_opts.add("rationale")
|
|
103
|
+
if not declared.intersection(_TITLE_SPELLINGS):
|
|
104
|
+
self.params.append(
|
|
105
|
+
click.Option(["--title", "title"], default=None, help=_TITLE_HELP)
|
|
106
|
+
)
|
|
107
|
+
self.injected_write_opts.add("title")
|
|
108
|
+
|
|
109
|
+
def invoke(self, ctx: click.Context):
|
|
110
|
+
# ``pop`` rather than read: the callback never declared these, so
|
|
111
|
+
# leaving them in ``ctx.params`` is an unexpected-keyword TypeError.
|
|
112
|
+
# A command that declared its own keeps it — nothing to pop, and the
|
|
113
|
+
# callback receives it the ordinary way.
|
|
114
|
+
if "rationale" in self.injected_write_opts:
|
|
115
|
+
ctx.meta[_RATIONALE_META_KEY] = ctx.params.pop("rationale", None)
|
|
116
|
+
if "title" in self.injected_write_opts:
|
|
117
|
+
ctx.meta[_TITLE_META_KEY] = ctx.params.pop("title", None)
|
|
118
|
+
return super().invoke(ctx)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class FileFirstWriteCommand(WriteCommand, FileFirstCommand):
|
|
122
|
+
"""``FILENAME <ID>`` write commands — most of the `reasoning` family."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class NegativeNumberWriteCommand(WriteCommand, NegativeNumberCommand):
|
|
126
|
+
"""A write command taking a negative literal in its last positional."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def write_message() -> tuple[str | None, str | None]:
|
|
130
|
+
"""``(title, rationale)`` parked by :class:`WriteCommand` for this call.
|
|
131
|
+
|
|
132
|
+
Returns ``(None, None)`` outside a Click context, and for a command that
|
|
133
|
+
is not a :class:`WriteCommand` — the caller then supplies its own, which
|
|
134
|
+
is how `doc` passes the ``--rationale`` its callbacks declare.
|
|
135
|
+
"""
|
|
136
|
+
ctx = click.get_current_context(silent=True)
|
|
137
|
+
if ctx is None:
|
|
138
|
+
return None, None
|
|
139
|
+
return ctx.meta.get(_TITLE_META_KEY), ctx.meta.get(_RATIONALE_META_KEY)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Account data-rights commands: export, delete.
|
|
2
|
+
|
|
3
|
+
Thin pass-through to ``GET /auth/me/export`` and ``DELETE /auth/me``. These are
|
|
4
|
+
the two rights the privacy policy says take effect immediately rather than by
|
|
5
|
+
emailing support, so they exist as commands and not just as a mailbox.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from deepcell_cli.config import clear_credentials
|
|
16
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
17
|
+
from deepcell_cli.errors import APIError
|
|
18
|
+
from deepcell_cli.output import echo_error, echo_info, echo_success, output
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
def account() -> None:
|
|
23
|
+
"""Export or delete the data held on your DeepCell account."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@account.command("export")
|
|
27
|
+
@click.option(
|
|
28
|
+
"--output",
|
|
29
|
+
"-o",
|
|
30
|
+
"out_path",
|
|
31
|
+
type=click.Path(dir_okay=False, writable=True, path_type=Path),
|
|
32
|
+
default=None,
|
|
33
|
+
help="Write the export to this file instead of stdout.",
|
|
34
|
+
)
|
|
35
|
+
@pass_ctx
|
|
36
|
+
def account_export(ctx: Ctx, out_path: Path | None) -> None:
|
|
37
|
+
"""Download every record DeepCell holds about your account.
|
|
38
|
+
|
|
39
|
+
Includes your profile, workspaces, file index, share links, API key
|
|
40
|
+
metadata, sessions, and activity records. File *contents* are not included
|
|
41
|
+
— fetch those with `deepcell pull`. Authentication secrets are excluded by
|
|
42
|
+
design.
|
|
43
|
+
"""
|
|
44
|
+
data = ctx.client.get("/auth/me/export")
|
|
45
|
+
if out_path is None:
|
|
46
|
+
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
47
|
+
return
|
|
48
|
+
out_path.write_text(
|
|
49
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
50
|
+
)
|
|
51
|
+
echo_success(f"Export written to {out_path}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@account.command("delete")
|
|
55
|
+
@click.option(
|
|
56
|
+
"--confirm-email",
|
|
57
|
+
default=None,
|
|
58
|
+
help="Your account email. Prompted for if omitted.",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--password",
|
|
62
|
+
default=None,
|
|
63
|
+
help="Account password. Prompted for if the account has one.",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--yes",
|
|
67
|
+
is_flag=True,
|
|
68
|
+
help="Skip the interactive 'this is irreversible' confirmation.",
|
|
69
|
+
)
|
|
70
|
+
@pass_ctx
|
|
71
|
+
def account_delete(
|
|
72
|
+
ctx: Ctx, confirm_email: str | None, password: str | None, yes: bool
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Permanently delete your account and everything it owns.
|
|
75
|
+
|
|
76
|
+
This cannot be undone. Your workspaces, files, share links, and API keys
|
|
77
|
+
are destroyed, and your activity records are de-identified.
|
|
78
|
+
|
|
79
|
+
A workspace you share with other people is handed to another owner. If it
|
|
80
|
+
has no other owner, the deletion is refused so their data is not destroyed
|
|
81
|
+
with yours — transfer ownership or remove the other members first.
|
|
82
|
+
"""
|
|
83
|
+
if confirm_email is None:
|
|
84
|
+
confirm_email = click.prompt("Confirm your account email")
|
|
85
|
+
if not yes:
|
|
86
|
+
click.confirm(
|
|
87
|
+
f"Permanently delete {confirm_email} and all of its data? "
|
|
88
|
+
"This cannot be undone.",
|
|
89
|
+
abort=True,
|
|
90
|
+
)
|
|
91
|
+
if password is None:
|
|
92
|
+
# An empty entry means "OAuth-only account, no password" — the server
|
|
93
|
+
# decides whether one was required, so we do not guess here.
|
|
94
|
+
password = (
|
|
95
|
+
click.prompt(
|
|
96
|
+
"Account password (leave blank if you sign in with Google)",
|
|
97
|
+
hide_input=True,
|
|
98
|
+
default="",
|
|
99
|
+
show_default=False,
|
|
100
|
+
)
|
|
101
|
+
or None
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
body: dict[str, str] = {"confirm_email": confirm_email}
|
|
105
|
+
if password:
|
|
106
|
+
body["password"] = password
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
resp = ctx.client.delete("/auth/me", json=body)
|
|
110
|
+
except APIError as exc:
|
|
111
|
+
detail = exc.payload
|
|
112
|
+
if isinstance(detail, dict) and detail.get("workspaces"):
|
|
113
|
+
echo_error(detail.get("message", "Account deletion blocked."))
|
|
114
|
+
for workspace in detail["workspaces"]:
|
|
115
|
+
echo_info(f" - {workspace.get('name')} ({workspace.get('id')})")
|
|
116
|
+
raise SystemExit(1) from exc
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
output(resp.json(), ctx.fmt)
|
|
120
|
+
# The tokens on disk now authenticate nothing; leaving them would make the
|
|
121
|
+
# next command fail with a confusing 401 instead of "you are logged out".
|
|
122
|
+
clear_credentials()
|
|
123
|
+
echo_success("Account deleted. Local credentials have been cleared.")
|