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