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,185 @@
|
|
|
1
|
+
"""``deepcell upgrade`` — the upgrade check's switch, its state, and how to upgrade.
|
|
2
|
+
|
|
3
|
+
The group deliberately does **not** install anything. Running ``pip`` from
|
|
4
|
+
inside the running CLI would have to guess how this copy was installed (pip,
|
|
5
|
+
pipx, uv, a venv, the system package manager) and would rewrite the very
|
|
6
|
+
process executing the command. So the CLI reports and prints the exact
|
|
7
|
+
command; the human runs it.
|
|
8
|
+
|
|
9
|
+
Bare ``deepcell upgrade`` shows status rather than erroring, because that is
|
|
10
|
+
what someone typing it is asking — "am I current, and if not, what do I run?"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from deepcell_cli import __version__
|
|
21
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
22
|
+
from deepcell_cli.output import echo_success
|
|
23
|
+
from deepcell_cli import upgrade_check
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _report() -> dict:
|
|
27
|
+
"""Everything the two read-only views show, in one shape."""
|
|
28
|
+
state = upgrade_check.read_state()
|
|
29
|
+
latest = state.get("latest")
|
|
30
|
+
checked_at = state.get("checked_at")
|
|
31
|
+
known_newer = upgrade_check.is_newer(latest)
|
|
32
|
+
install_command = (
|
|
33
|
+
state.get("install_command")
|
|
34
|
+
or f"pip install --upgrade {upgrade_check.PACKAGE_NAME}"
|
|
35
|
+
)
|
|
36
|
+
return {
|
|
37
|
+
"current": __version__,
|
|
38
|
+
"latest_known": latest if isinstance(latest, str) else None,
|
|
39
|
+
"upgrade_available": known_newer,
|
|
40
|
+
"auto_check_enabled": upgrade_check.config_enabled(),
|
|
41
|
+
"silenced_by_env": upgrade_check.env_silenced(),
|
|
42
|
+
"last_checked": (
|
|
43
|
+
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(checked_at))
|
|
44
|
+
if isinstance(checked_at, (int, float)) and not isinstance(checked_at, bool)
|
|
45
|
+
else None
|
|
46
|
+
),
|
|
47
|
+
"check_interval_hours": upgrade_check.CHECK_INTERVAL_SECONDS // 3600,
|
|
48
|
+
# Only when there is something to install. Printing an install line
|
|
49
|
+
# to someone already current reads as "you should run this".
|
|
50
|
+
"install_command": install_command if known_newer else None,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render(report: dict) -> list[str]:
|
|
55
|
+
lines = [f"deepcell {report['current']}"]
|
|
56
|
+
if report["latest_known"] is None:
|
|
57
|
+
lines.append(" Latest unknown — run `deepcell upgrade check`")
|
|
58
|
+
elif report["upgrade_available"]:
|
|
59
|
+
lines.append(f" Latest {report['latest_known']} — upgrade available")
|
|
60
|
+
else:
|
|
61
|
+
lines.append(f" Latest {report['latest_known']} — up to date")
|
|
62
|
+
|
|
63
|
+
if report["silenced_by_env"]:
|
|
64
|
+
state = f"silenced by {upgrade_check.ENV_DISABLE}"
|
|
65
|
+
elif report["auto_check_enabled"]:
|
|
66
|
+
state = f"on — at most once every {report['check_interval_hours']}h, in the background"
|
|
67
|
+
else:
|
|
68
|
+
state = "off — run `deepcell upgrade enable` to turn it back on"
|
|
69
|
+
lines.append(f" Auto-check {state}")
|
|
70
|
+
lines.append(f" Last check {report['last_checked'] or 'never'}")
|
|
71
|
+
if report["install_command"]:
|
|
72
|
+
lines.append("")
|
|
73
|
+
lines.append(f" {report['install_command']}")
|
|
74
|
+
return lines
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _emit(ctx: Ctx, report: dict) -> None:
|
|
78
|
+
if ctx.fmt == "json":
|
|
79
|
+
click.echo(json.dumps(report, indent=2))
|
|
80
|
+
return
|
|
81
|
+
for line in _render(report):
|
|
82
|
+
click.echo(line)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@click.group(invoke_without_command=True)
|
|
86
|
+
@click.pass_context
|
|
87
|
+
def upgrade(ctx: click.Context) -> None:
|
|
88
|
+
"""Check whether a newer deepcell CLI has been published.
|
|
89
|
+
|
|
90
|
+
The check runs by itself at most once a day, in a background thread, and
|
|
91
|
+
prints a one-line notice on stderr when a newer version exists. It never
|
|
92
|
+
installs anything and never delays a command — it tells you what to run.
|
|
93
|
+
|
|
94
|
+
It is on by default. Turn it off persistently with `deepcell upgrade
|
|
95
|
+
disable`, or for one process with `DEEPCELL_NO_UPGRADE_CHECK=1`.
|
|
96
|
+
|
|
97
|
+
\b
|
|
98
|
+
Examples:
|
|
99
|
+
deepcell upgrade # am I current, and what do I run?
|
|
100
|
+
deepcell upgrade check # ask the index right now
|
|
101
|
+
deepcell upgrade disable # stop the automatic notice
|
|
102
|
+
"""
|
|
103
|
+
if ctx.invoked_subcommand is None:
|
|
104
|
+
ctx.invoke(status)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@upgrade.command("status")
|
|
108
|
+
@pass_ctx
|
|
109
|
+
def status(ctx: Ctx) -> None:
|
|
110
|
+
"""Show the setting and the last cached answer. Never touches the network.
|
|
111
|
+
|
|
112
|
+
\b
|
|
113
|
+
Examples:
|
|
114
|
+
deepcell upgrade status
|
|
115
|
+
deepcell upgrade status --format json
|
|
116
|
+
"""
|
|
117
|
+
_emit(ctx, _report())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@upgrade.command("check")
|
|
121
|
+
@click.option(
|
|
122
|
+
"--timeout",
|
|
123
|
+
type=float,
|
|
124
|
+
default=upgrade_check.FETCH_TIMEOUT_SECONDS,
|
|
125
|
+
show_default=True,
|
|
126
|
+
metavar="SECONDS",
|
|
127
|
+
help="How long to wait for each package index.",
|
|
128
|
+
)
|
|
129
|
+
@pass_ctx
|
|
130
|
+
def check(ctx: Ctx, timeout: float) -> None:
|
|
131
|
+
"""Ask the package index right now and update the cached answer.
|
|
132
|
+
|
|
133
|
+
Runs even when the automatic check is disabled — asking explicitly is not
|
|
134
|
+
the thing that was turned off. Exits 0 whether or not an upgrade exists;
|
|
135
|
+
an unreachable index is reported, not raised.
|
|
136
|
+
|
|
137
|
+
\b
|
|
138
|
+
Examples:
|
|
139
|
+
deepcell upgrade check
|
|
140
|
+
deepcell upgrade check --timeout 10
|
|
141
|
+
"""
|
|
142
|
+
state = upgrade_check.refresh(timeout)
|
|
143
|
+
report = _report()
|
|
144
|
+
if ctx.fmt == "json":
|
|
145
|
+
_emit(ctx, report)
|
|
146
|
+
return
|
|
147
|
+
if not state.get("ok"):
|
|
148
|
+
click.echo(
|
|
149
|
+
"Could not reach the package index — showing the last known answer.",
|
|
150
|
+
err=True,
|
|
151
|
+
)
|
|
152
|
+
_emit(ctx, report)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@upgrade.command("enable")
|
|
156
|
+
def enable() -> None:
|
|
157
|
+
"""Turn the automatic upgrade check on (the default).
|
|
158
|
+
|
|
159
|
+
\b
|
|
160
|
+
Examples:
|
|
161
|
+
deepcell upgrade enable
|
|
162
|
+
"""
|
|
163
|
+
upgrade_check.set_enabled(True)
|
|
164
|
+
echo_success("Automatic upgrade check enabled.")
|
|
165
|
+
if upgrade_check.env_silenced():
|
|
166
|
+
click.echo(
|
|
167
|
+
f"Note: {upgrade_check.ENV_DISABLE} is set in this environment, "
|
|
168
|
+
"which still silences the notice here.",
|
|
169
|
+
err=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@upgrade.command("disable")
|
|
174
|
+
def disable() -> None:
|
|
175
|
+
"""Turn the automatic upgrade check off.
|
|
176
|
+
|
|
177
|
+
Nothing is checked and no notice is printed until `deepcell upgrade
|
|
178
|
+
enable`. `deepcell upgrade check` still works when asked directly.
|
|
179
|
+
|
|
180
|
+
\b
|
|
181
|
+
Examples:
|
|
182
|
+
deepcell upgrade disable
|
|
183
|
+
"""
|
|
184
|
+
upgrade_check.set_enabled(False)
|
|
185
|
+
echo_success("Automatic upgrade check disabled.")
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Variant commands: list, create, checkout, diff, merge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
8
|
+
from deepcell_cli.output import (
|
|
9
|
+
echo_error,
|
|
10
|
+
echo_info,
|
|
11
|
+
echo_success,
|
|
12
|
+
echo_validation_map,
|
|
13
|
+
echo_warning,
|
|
14
|
+
output,
|
|
15
|
+
output_mutation,
|
|
16
|
+
)
|
|
17
|
+
from deepcell_cli.sync_state import find_sync_root, load_sync_state, save_sync_state
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _require_sync_root_for_variant():
|
|
21
|
+
"""Locate sync root from CWD or raise a ClickException."""
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
root = find_sync_root()
|
|
25
|
+
if root is None:
|
|
26
|
+
raise click.ClickException(
|
|
27
|
+
"Not inside a synced workspace. Run `deepcell clone <slug>` first."
|
|
28
|
+
)
|
|
29
|
+
return root, load_sync_state(root)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _dirty_files(root, state) -> set[str]:
|
|
33
|
+
"""Return tracked files that are missing/changed plus untracked files.
|
|
34
|
+
|
|
35
|
+
Mirrors how `status` computes A/M/D (added + modified + deleted).
|
|
36
|
+
"""
|
|
37
|
+
from deepcell_cli.commands.sync import scan_local_files
|
|
38
|
+
|
|
39
|
+
local = scan_local_files(root, tracked=state.file_checksums)
|
|
40
|
+
changed_or_deleted = {
|
|
41
|
+
n for n, ck in state.file_checksums.items()
|
|
42
|
+
if n not in local or local[n] != ck.local_hash
|
|
43
|
+
}
|
|
44
|
+
untracked = {n for n in local if n not in state.file_checksums}
|
|
45
|
+
return changed_or_deleted | untracked
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sync_branch_files(client, slug: str, root, state, variant: str | None) -> None:
|
|
49
|
+
"""Atomically reconcile the working tree to the target branch's files.
|
|
50
|
+
|
|
51
|
+
The switch is all-or-nothing: ALL target-branch file contents are downloaded
|
|
52
|
+
into memory FIRST, and only once every download has succeeded do we touch the
|
|
53
|
+
disk — writing files, rebuilding checksums, and removing stranded files. If any
|
|
54
|
+
download fails, we abort having written NOTHING to disk and leaving
|
|
55
|
+
``state.file_checksums`` unchanged; the caller never reaches ``save_sync_state``,
|
|
56
|
+
so sync.json keeps pointing at the previous branch. This prevents a partial
|
|
57
|
+
switch where files 1..k-1 already hold the target branch's content on disk while
|
|
58
|
+
sync.json still claims the old branch — which a later main-mode push would
|
|
59
|
+
happily upload onto main (silent cross-branch corruption).
|
|
60
|
+
|
|
61
|
+
Files that were tracked on the previous branch but are absent from the target
|
|
62
|
+
branch are deleted from disk, so switching branches does not leave orphan files
|
|
63
|
+
behind (which would otherwise resurface as untracked/added on the next status
|
|
64
|
+
or push and get re-uploaded onto the wrong branch). Callers must ensure the
|
|
65
|
+
working tree is clean first (checkout refuses on uncommitted changes), so every
|
|
66
|
+
local file at this point is tracked — untracked user files are never removed.
|
|
67
|
+
"""
|
|
68
|
+
from deepcell_cli.commands.sync import _download_branch_file, _get_remote_files
|
|
69
|
+
from deepcell_cli.sync_state import FileChecksum, compute_local_hash
|
|
70
|
+
|
|
71
|
+
previously_tracked = set(state.file_checksums)
|
|
72
|
+
remote_files = _get_remote_files(client, slug, variant=variant)
|
|
73
|
+
|
|
74
|
+
# Phase 1 — download everything into memory. A failure here propagates before
|
|
75
|
+
# any disk mutation, so the previous branch stays fully intact on disk.
|
|
76
|
+
downloaded: list[tuple[str, str, str]] = [] # (fname, content, git_sha)
|
|
77
|
+
for f in remote_files:
|
|
78
|
+
fname = f["filename"]
|
|
79
|
+
content = _download_branch_file(client, slug, fname, variant=variant)
|
|
80
|
+
downloaded.append((fname, content, f.get("git_sha", "")))
|
|
81
|
+
|
|
82
|
+
# Phase 2 — commit. Every download succeeded; write each file to a hidden
|
|
83
|
+
# temp sibling FIRST, then atomically rename it into place. A mid-write
|
|
84
|
+
# failure (ENOSPC/EACCES) therefore leaves no final file holding the target
|
|
85
|
+
# branch's content — without this, files 1..k-1 would already be switched on
|
|
86
|
+
# disk while sync.json still claimed the old branch, which a later main-mode
|
|
87
|
+
# push would upload onto main (silent cross-branch corruption). Temp names
|
|
88
|
+
# are dot-prefixed so scan_local_files ignores any that a crash leaves behind.
|
|
89
|
+
import os
|
|
90
|
+
|
|
91
|
+
new_checksums: dict[str, FileChecksum] = {}
|
|
92
|
+
staged: list[tuple] = [] # (temp_path, final_path)
|
|
93
|
+
temp_paths: list = []
|
|
94
|
+
try:
|
|
95
|
+
for fname, content, git_sha in downloaded:
|
|
96
|
+
final = root / fname
|
|
97
|
+
tmp = final.with_name(f".{final.name}.tmp")
|
|
98
|
+
final.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
tmp.write_text(content, encoding="utf-8")
|
|
100
|
+
temp_paths.append(tmp)
|
|
101
|
+
staged.append((tmp, final))
|
|
102
|
+
new_checksums[fname] = FileChecksum(
|
|
103
|
+
git_sha=git_sha,
|
|
104
|
+
local_hash=compute_local_hash(content),
|
|
105
|
+
)
|
|
106
|
+
# All temps written — promote them (fast, per-file atomic).
|
|
107
|
+
for tmp, final in staged:
|
|
108
|
+
os.replace(tmp, final)
|
|
109
|
+
finally:
|
|
110
|
+
# Remove any temp not promoted (a write failed mid-loop).
|
|
111
|
+
for tmp in temp_paths:
|
|
112
|
+
tmp.unlink(missing_ok=True)
|
|
113
|
+
|
|
114
|
+
# Remove files tracked on the old branch but not present on the target branch.
|
|
115
|
+
for stale in previously_tracked - set(new_checksums):
|
|
116
|
+
(root / stale).unlink(missing_ok=True)
|
|
117
|
+
|
|
118
|
+
state.file_checksums = new_checksums
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@click.group()
|
|
122
|
+
def variant() -> None:
|
|
123
|
+
"""Manage variants (what-if branches).
|
|
124
|
+
|
|
125
|
+
Variants let you create parallel assumption sets for the same model —
|
|
126
|
+
e.g. Base Case, Best Case, Worst Case — without duplicating files.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@variant.command("list")
|
|
131
|
+
@pass_ctx
|
|
132
|
+
def variant_list(ctx: Ctx) -> None:
|
|
133
|
+
"""List variants in the workspace."""
|
|
134
|
+
slug = ctx.require_workspace()
|
|
135
|
+
data = ctx.client.get(f"/workspaces/{slug}/variants")
|
|
136
|
+
output(data, ctx.fmt)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@variant.command("create")
|
|
140
|
+
@click.argument("name")
|
|
141
|
+
@pass_ctx
|
|
142
|
+
def variant_create(ctx: Ctx, name: str) -> None:
|
|
143
|
+
"""Create a new variant branch."""
|
|
144
|
+
slug = ctx.require_workspace()
|
|
145
|
+
data = ctx.client.post(f"/workspaces/{slug}/variants/{name}", json={})
|
|
146
|
+
output_mutation(data, ctx.fmt, plain_key="variant_name")
|
|
147
|
+
echo_success(f"Variant '{name}' created")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@variant.command("checkout")
|
|
151
|
+
@click.argument("name", required=False, default=None)
|
|
152
|
+
@click.option("--main", "use_main", is_flag=True, default=False,
|
|
153
|
+
help="Switch back to the main branch (clear active variant).")
|
|
154
|
+
@pass_ctx
|
|
155
|
+
def variant_checkout(ctx: Ctx, name: str | None, use_main: bool) -> None:
|
|
156
|
+
"""Check out a variant branch for local edits.
|
|
157
|
+
|
|
158
|
+
\b
|
|
159
|
+
deepcell variant checkout exp-1 # switch to variant exp-1
|
|
160
|
+
deepcell variant checkout main # return to main
|
|
161
|
+
deepcell variant checkout --main # same as above
|
|
162
|
+
"""
|
|
163
|
+
from deepcell_cli.commands.sync import _get_head_sha
|
|
164
|
+
|
|
165
|
+
root, state = _require_sync_root_for_variant()
|
|
166
|
+
slug = state.workspace_slug
|
|
167
|
+
target_main = use_main or name == "main"
|
|
168
|
+
|
|
169
|
+
# Refuse to switch with uncommitted local changes — a branch switch
|
|
170
|
+
# overwrites the working tree, so dirty state would be silently clobbered.
|
|
171
|
+
dirty = _dirty_files(root, state)
|
|
172
|
+
if dirty:
|
|
173
|
+
raise click.ClickException(
|
|
174
|
+
"You have uncommitted local changes: "
|
|
175
|
+
+ ", ".join(sorted(dirty))
|
|
176
|
+
+ ". Push or discard them before switching branches."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if target_main:
|
|
180
|
+
# Pin the optimistic-lock baseline to main's HEAD. Hard-fail on an empty
|
|
181
|
+
# SHA rather than falling back to the variant's last_sync_sha (which would
|
|
182
|
+
# leave the baseline pointing at the variant after switching to main and
|
|
183
|
+
# cause spurious "Remote has new changes" on the next push).
|
|
184
|
+
main_head = _get_head_sha(ctx.client, slug)
|
|
185
|
+
if not main_head:
|
|
186
|
+
raise click.ClickException(
|
|
187
|
+
"Could not determine main HEAD SHA — cannot set lock baseline. "
|
|
188
|
+
"Try again, or run `deepcell pull` first."
|
|
189
|
+
)
|
|
190
|
+
state.active_variant = ""
|
|
191
|
+
state.last_sync_sha = main_head
|
|
192
|
+
_sync_branch_files(ctx.client, slug, root, state, variant=None)
|
|
193
|
+
save_sync_state(root, state)
|
|
194
|
+
echo_success("Switched to main branch")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
if not name:
|
|
198
|
+
raise click.ClickException("Provide a variant name or use --main to return to main.")
|
|
199
|
+
|
|
200
|
+
# Validate the variant exists (404 → APIError → ClickException).
|
|
201
|
+
try:
|
|
202
|
+
detail = ctx.client.get(f"/workspaces/{slug}/variants/{name}")
|
|
203
|
+
except Exception as e:
|
|
204
|
+
raise click.ClickException(
|
|
205
|
+
f"Variant '{name}' does not exist (or is unreachable): {e}. "
|
|
206
|
+
f"Create it with `deepcell variant create {name}`."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if not isinstance(detail, dict) or not detail.get("head_sha"):
|
|
210
|
+
raise click.ClickException(
|
|
211
|
+
f"Variant '{name}' response missing head_sha — cannot set lock baseline."
|
|
212
|
+
)
|
|
213
|
+
state.active_variant = name
|
|
214
|
+
state.last_sync_sha = detail["head_sha"]
|
|
215
|
+
_sync_branch_files(ctx.client, slug, root, state, variant=name)
|
|
216
|
+
save_sync_state(root, state)
|
|
217
|
+
echo_success(f"Switched to variant '{name}'")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@variant.command("diff")
|
|
221
|
+
@click.argument("name", required=False, default=None)
|
|
222
|
+
@click.option("--file", "file_path", default=None, help="Limit the diff to a single file.")
|
|
223
|
+
@pass_ctx
|
|
224
|
+
def variant_diff(ctx: Ctx, name: str | None, file_path: str | None) -> None:
|
|
225
|
+
"""Show diff between a variant branch and main.
|
|
226
|
+
|
|
227
|
+
NAME defaults to the currently checked-out variant (active_variant).
|
|
228
|
+
"""
|
|
229
|
+
root, state = _require_sync_root_for_variant()
|
|
230
|
+
slug = state.workspace_slug
|
|
231
|
+
|
|
232
|
+
variant_name = name or state.active_variant
|
|
233
|
+
if not variant_name:
|
|
234
|
+
raise click.ClickException(
|
|
235
|
+
"No variant name given and no active variant. "
|
|
236
|
+
"Run `deepcell variant checkout <name>` first, or pass a name."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
params = {"filename": file_path} if file_path else None
|
|
240
|
+
data = ctx.client.get(f"/workspaces/{slug}/variants/{variant_name}/diff", params=params)
|
|
241
|
+
output(data, ctx.fmt)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@variant.command("merge")
|
|
245
|
+
@click.argument("name", required=False, default=None)
|
|
246
|
+
@click.option(
|
|
247
|
+
"--resolve",
|
|
248
|
+
type=click.Choice(["ours", "theirs"]),
|
|
249
|
+
default=None,
|
|
250
|
+
help="Conflict resolution strategy: 'ours' keeps main, 'theirs' keeps variant.",
|
|
251
|
+
)
|
|
252
|
+
@pass_ctx
|
|
253
|
+
def variant_merge(ctx: Ctx, name: str | None, resolve: str | None) -> None:
|
|
254
|
+
"""Merge a variant branch back into main.
|
|
255
|
+
|
|
256
|
+
NAME defaults to the currently checked-out variant (active_variant).
|
|
257
|
+
|
|
258
|
+
On merge conflicts, re-run with --resolve ours or --resolve theirs.
|
|
259
|
+
"""
|
|
260
|
+
root, state = _require_sync_root_for_variant()
|
|
261
|
+
slug = state.workspace_slug
|
|
262
|
+
|
|
263
|
+
variant_name = name or state.active_variant
|
|
264
|
+
if not variant_name:
|
|
265
|
+
raise click.ClickException(
|
|
266
|
+
"No variant name given and no active variant. "
|
|
267
|
+
"Run `deepcell variant checkout <name>` first, or pass a name."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
data = ctx.client.post(
|
|
271
|
+
f"/workspaces/{slug}/variants/{variant_name}/merge",
|
|
272
|
+
json={"resolve": resolve},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _warn_skipped(d: dict) -> None:
|
|
276
|
+
skipped_nd = d.get("skipped_non_deepcell") or []
|
|
277
|
+
skipped_del = d.get("skipped_deletes") or []
|
|
278
|
+
unmerged = d.get("unmerged_items") or []
|
|
279
|
+
if skipped_nd:
|
|
280
|
+
echo_warning(
|
|
281
|
+
"Not merged (non-.deepcell files — copy them onto main manually): "
|
|
282
|
+
+ ", ".join(skipped_nd)
|
|
283
|
+
)
|
|
284
|
+
if skipped_del:
|
|
285
|
+
echo_warning(
|
|
286
|
+
"Not merged (files deleted on the variant — delete them on main "
|
|
287
|
+
"manually if intended): " + ", ".join(skipped_del)
|
|
288
|
+
)
|
|
289
|
+
if unmerged:
|
|
290
|
+
echo_warning(
|
|
291
|
+
"Not merged (items the value-merge could not carry over — add them "
|
|
292
|
+
"to main manually): " + ", ".join(unmerged)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if isinstance(data, dict) and data.get("success"):
|
|
296
|
+
commit_sha = data.get("commit_sha") or ""
|
|
297
|
+
echo_success(f"Merged variant '{variant_name}' into main ({commit_sha[:8] if commit_sha else 'ok'})")
|
|
298
|
+
_warn_skipped(data)
|
|
299
|
+
# The merge recomputes and validates each merged .deepcell before
|
|
300
|
+
# committing. It commits either way, so invalid content must be
|
|
301
|
+
# reported here or it lands on main behind a success message.
|
|
302
|
+
# Report BOTH before exiting: these are independent failures and the
|
|
303
|
+
# data-loss message names work the user has to redo by hand. Exiting on
|
|
304
|
+
# the first one swallowed the second whenever they coincided — the same
|
|
305
|
+
# swallowed-signal class this sweep exists to close.
|
|
306
|
+
invalid = echo_validation_map(data.get("validation"))
|
|
307
|
+
if invalid:
|
|
308
|
+
echo_error(
|
|
309
|
+
f"Merge committed, but {len(invalid)} merged file(s) have validation "
|
|
310
|
+
f"errors: {', '.join(invalid)} — fix them on main"
|
|
311
|
+
)
|
|
312
|
+
if data.get("had_data_loss"):
|
|
313
|
+
echo_error(
|
|
314
|
+
"Merge committed but some changes were NOT carried over (see warnings "
|
|
315
|
+
"above). Apply them on main manually."
|
|
316
|
+
)
|
|
317
|
+
if invalid or data.get("had_data_loss"):
|
|
318
|
+
raise click.exceptions.Exit(1)
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
# Not successful — show conflicts.
|
|
322
|
+
# Keys mirror the backend's _serialise_conflict: file, item_ref, context_ref,
|
|
323
|
+
# status_ref, base_value, ours_value (main), theirs_value (variant).
|
|
324
|
+
conflicts = data.get("conflicts", []) if isinstance(data, dict) else []
|
|
325
|
+
if conflicts:
|
|
326
|
+
echo_error(f"Merge conflict in variant '{variant_name}':")
|
|
327
|
+
for c in conflicts:
|
|
328
|
+
fname = c.get("file", "?")
|
|
329
|
+
item = c.get("item_ref", "?")
|
|
330
|
+
cref = c.get("context_ref") or ""
|
|
331
|
+
sref = c.get("status_ref") or ""
|
|
332
|
+
coords = ", ".join(p for p in (cref, sref) if p)
|
|
333
|
+
loc = f"{item}[{coords}]" if coords else item
|
|
334
|
+
|
|
335
|
+
def _val(v: object) -> str:
|
|
336
|
+
return "(absent)" if v is None else str(v)
|
|
337
|
+
|
|
338
|
+
click.echo(
|
|
339
|
+
f" {fname} {loc}: ours(main)={_val(c.get('ours_value'))} "
|
|
340
|
+
f"theirs(variant)={_val(c.get('theirs_value'))}",
|
|
341
|
+
err=True,
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
echo_error(f"Merge failed for variant '{variant_name}'")
|
|
345
|
+
|
|
346
|
+
if isinstance(data, dict):
|
|
347
|
+
_warn_skipped(data)
|
|
348
|
+
|
|
349
|
+
click.echo(
|
|
350
|
+
"Hint: re-run with --resolve ours (keep main) or --resolve theirs (keep variant).",
|
|
351
|
+
err=True,
|
|
352
|
+
)
|
|
353
|
+
raise click.exceptions.Exit(1)
|