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,110 @@
|
|
|
1
|
+
"""``deepcell ingest`` — pull external filings into extractable data.
|
|
2
|
+
|
|
3
|
+
Thin pass-through to the Jingwei ``/ingest`` endpoints. Currently the ``cn``
|
|
4
|
+
group covers cninfo (巨潮资讯网) A-share annual / interim reports:
|
|
5
|
+
|
|
6
|
+
\b
|
|
7
|
+
deepcell ingest cn search "贵州茅台"
|
|
8
|
+
deepcell ingest cn filings 600519 --type annual
|
|
9
|
+
deepcell ingest cn statements <pdf_url>
|
|
10
|
+
deepcell ingest cn extract <pdf_url> --statement 合并利润表
|
|
11
|
+
|
|
12
|
+
The typical flow: search → filings (copy a PDF url) → statements (find the
|
|
13
|
+
page) → extract (rows + canonical item ids + a #page deep link). Feed the
|
|
14
|
+
result to a modeler / `deepcell write` per `deepcell guide cn-extraction`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
22
|
+
from deepcell_cli.output import echo_warning, output, print_plain
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
def ingest() -> None:
|
|
27
|
+
"""Pull external filings into extractable statement data."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@ingest.group()
|
|
31
|
+
def cn() -> None:
|
|
32
|
+
"""cninfo (A-share) filings — Shanghai / Shenzhen / Beijing exchanges."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cn.command("search")
|
|
36
|
+
@click.argument("keywords")
|
|
37
|
+
@pass_ctx
|
|
38
|
+
def cn_search(ctx: Ctx, keywords: str) -> None:
|
|
39
|
+
"""Resolve a 6-digit code, company name (中文简称), or pinyin."""
|
|
40
|
+
data = ctx.client.get("/ingest/cn/search", params={"keywords": keywords})
|
|
41
|
+
output(data.get("matches", data), ctx.fmt)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cn.command("filings")
|
|
45
|
+
@click.argument("code")
|
|
46
|
+
@click.option("--type", "filing_type", default="annual",
|
|
47
|
+
help="annual | interim | semiannual | q1 | q3")
|
|
48
|
+
@click.option("--count", default=5, type=click.IntRange(1, 30),
|
|
49
|
+
help="number of reports (1-30)")
|
|
50
|
+
@pass_ctx
|
|
51
|
+
def cn_filings(ctx: Ctx, code: str, filing_type: str, count: int) -> None:
|
|
52
|
+
"""List annual / interim reports with PDF permalinks."""
|
|
53
|
+
data = ctx.client.get(
|
|
54
|
+
"/ingest/cn/filings",
|
|
55
|
+
params={"code": code, "filing_type": filing_type, "count": count},
|
|
56
|
+
)
|
|
57
|
+
output(data.get("filings", data), ctx.fmt)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cn.command("statements")
|
|
61
|
+
@click.argument("pdf_url")
|
|
62
|
+
@pass_ctx
|
|
63
|
+
def cn_statements(ctx: Ctx, pdf_url: str) -> None:
|
|
64
|
+
"""Locate the financial statements inside a report PDF (page + scale)."""
|
|
65
|
+
data = ctx.client.get("/ingest/cn/statements", params={"pdf_url": pdf_url})
|
|
66
|
+
output(data.get("statements", data), ctx.fmt)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cn.command("extract")
|
|
70
|
+
@click.argument("pdf_url")
|
|
71
|
+
@click.option("--statement", default="合并利润表",
|
|
72
|
+
help="Chinese heading or income | balance | cash_flow")
|
|
73
|
+
@click.option(
|
|
74
|
+
"--persist/--no-persist",
|
|
75
|
+
"persist",
|
|
76
|
+
default=True,
|
|
77
|
+
help="Mirror the source PDF into DeepCell storage and return a durable "
|
|
78
|
+
"`source_page_url` to record as the <Source> <Locator>. --no-persist "
|
|
79
|
+
"skips the mirror (faster, but the only url you get back is the "
|
|
80
|
+
"volatile cninfo link).",
|
|
81
|
+
)
|
|
82
|
+
@pass_ctx
|
|
83
|
+
def cn_extract(ctx: Ctx, pdf_url: str, statement: str, persist: bool) -> None:
|
|
84
|
+
"""Extract one statement's table — rows, canonical items, #page deep link."""
|
|
85
|
+
data = ctx.client.get(
|
|
86
|
+
"/ingest/cn/extract",
|
|
87
|
+
# Key matches the `persist` query param on GET /ingest/cn/extract.
|
|
88
|
+
params={"pdf_url": pdf_url, "statement": statement, "persist": persist},
|
|
89
|
+
)
|
|
90
|
+
if ctx.fmt == "json":
|
|
91
|
+
output(data, ctx.fmt)
|
|
92
|
+
return
|
|
93
|
+
# Human-readable: the markdown table plus the provenance lines. The mirror
|
|
94
|
+
# url (source_page_url) is the deepcell-hosted copy to record as <Url>.
|
|
95
|
+
mirror = data.get("source_page_url") or data.get("deep_link")
|
|
96
|
+
mirror_error = data.get("mirror_error")
|
|
97
|
+
if mirror_error:
|
|
98
|
+
# Without this the volatile cninfo link is printed as the durable
|
|
99
|
+
# `<Url>` to record, indistinguishable from a real mirror.
|
|
100
|
+
echo_warning(
|
|
101
|
+
f"{mirror_error} The 'Source <Url>' below is the ORIGINAL cninfo "
|
|
102
|
+
f"link, which may move — re-run to retry mirroring."
|
|
103
|
+
)
|
|
104
|
+
print_plain(
|
|
105
|
+
f"Statement: {data.get('statement')} (page {data.get('page')})\n"
|
|
106
|
+
f"Source <Url>: {mirror}\n"
|
|
107
|
+
f"Original (cninfo): {data.get('original_url') or data.get('deep_link')}\n"
|
|
108
|
+
f"Scale: 10^{data.get('scale')} Currency: {data.get('currency')}\n\n"
|
|
109
|
+
f"{data.get('markdown', '')}"
|
|
110
|
+
)
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Merge commands: preview, apply, and convenience resolve for .deepcell files.
|
|
2
|
+
|
|
3
|
+
Provides semantic three-way merge for .deepcell XML files using the backend
|
|
4
|
+
merge service. Works both standalone and as part of the pull conflict workflow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
14
|
+
from deepcell_cli.output import echo_error, echo_info, echo_success, echo_warning
|
|
15
|
+
from deepcell_cli.sync_state import (
|
|
16
|
+
FileChecksum,
|
|
17
|
+
compute_local_hash,
|
|
18
|
+
find_sync_root,
|
|
19
|
+
load_sync_state,
|
|
20
|
+
save_sync_state,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# -- Helpers -------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_local(root: Path, filename: str) -> str:
|
|
28
|
+
"""Read a file from the sync root."""
|
|
29
|
+
path = root / filename
|
|
30
|
+
if not path.exists():
|
|
31
|
+
raise click.ClickException(f"File not found: {filename}")
|
|
32
|
+
return path.read_text(encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _download_file(client, slug: str, filename: str, *, revision: str | None = None) -> str:
|
|
36
|
+
"""Download a file from the workspace, optionally at a specific revision."""
|
|
37
|
+
params = {"revision": revision} if revision else None
|
|
38
|
+
data = client.get(f"/workspaces/{slug}/files/{filename}", params=params)
|
|
39
|
+
if isinstance(data, dict) and "content" in data:
|
|
40
|
+
return data["content"]
|
|
41
|
+
return str(data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _show_conflicts(conflicts: list[dict]) -> None:
|
|
45
|
+
"""Display conflict details to stderr."""
|
|
46
|
+
for c in conflicts:
|
|
47
|
+
item = c.get("item_ref", "?")
|
|
48
|
+
ctx = c.get("context_ref", "")
|
|
49
|
+
status = c.get("status_ref") or ""
|
|
50
|
+
ctype = c.get("type", "value")
|
|
51
|
+
ours = c.get("ours", "")
|
|
52
|
+
theirs = c.get("theirs", "")
|
|
53
|
+
base = c.get("base", "")
|
|
54
|
+
coords = ", ".join(p for p in (ctx, status) if p)
|
|
55
|
+
label = f"{item}[{coords}]" if coords else item
|
|
56
|
+
echo_warning(f" {ctype}: {label} base={base} ours={ours} theirs={theirs}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def echo_merge_data_loss(result: dict) -> bool:
|
|
60
|
+
"""Report items/values the merge could not carry over.
|
|
61
|
+
|
|
62
|
+
``success`` is computed from the conflict count alone and cannot reflect
|
|
63
|
+
an apply-time drop, so a merge that silently lost an item still comes back
|
|
64
|
+
``success=True``. Returns True when anything was dropped, so callers can
|
|
65
|
+
keep the ``.local`` backup and exit non-zero.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(result, dict):
|
|
68
|
+
return False
|
|
69
|
+
items = list(result.get("items_not_merged") or [])
|
|
70
|
+
values = list(result.get("values_not_merged") or [])
|
|
71
|
+
if not items and not values:
|
|
72
|
+
return False
|
|
73
|
+
if items:
|
|
74
|
+
echo_warning(
|
|
75
|
+
f"{len(items)} item(s) could NOT be merged and are missing from the "
|
|
76
|
+
f"result: {', '.join(items)}"
|
|
77
|
+
)
|
|
78
|
+
if values:
|
|
79
|
+
echo_warning(
|
|
80
|
+
f"{len(values)} value(s) could NOT be written to the merged "
|
|
81
|
+
f"document: {', '.join(values)}"
|
|
82
|
+
)
|
|
83
|
+
echo_warning(
|
|
84
|
+
"Re-apply them by hand — the merge is incomplete despite reporting success."
|
|
85
|
+
)
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _update_checksum(root: Path, state, filename: str, server_content: str) -> None:
|
|
90
|
+
"""Re-baseline a merged file against what the SERVER holds.
|
|
91
|
+
|
|
92
|
+
*server_content* is `theirs` — the content the merge was performed
|
|
93
|
+
against, which is still what is committed. The merge result exists only on
|
|
94
|
+
disk until it is pushed, so baselining it against the merged content made
|
|
95
|
+
`push` report "Nothing to push" and `status` report Clean, stranding the
|
|
96
|
+
resolved merge with no command left to reveal it.
|
|
97
|
+
"""
|
|
98
|
+
if filename in state.file_checksums:
|
|
99
|
+
state.file_checksums[filename] = FileChecksum(
|
|
100
|
+
git_sha=state.file_checksums[filename].git_sha,
|
|
101
|
+
local_hash=compute_local_hash(server_content),
|
|
102
|
+
)
|
|
103
|
+
save_sync_state(root, state)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# -- merge group ---------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@click.group()
|
|
110
|
+
def merge() -> None:
|
|
111
|
+
"""Merge .deepcell files with semantic three-way merge.
|
|
112
|
+
|
|
113
|
+
\b
|
|
114
|
+
Resolve sync conflicts:
|
|
115
|
+
deepcell merge resolve model.deepcell # auto-merge, show conflicts
|
|
116
|
+
deepcell merge resolve model.deepcell --ours # keep all local values
|
|
117
|
+
deepcell merge resolve model.deepcell --theirs # take all remote values
|
|
118
|
+
|
|
119
|
+
\b
|
|
120
|
+
Power-user subcommands:
|
|
121
|
+
deepcell merge preview model.deepcell # preview merge result
|
|
122
|
+
deepcell merge apply <session-id> --resolve ours # resolve by session ID
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# -- merge resolve (convenience) -----------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@merge.command()
|
|
130
|
+
@click.argument("filename")
|
|
131
|
+
@click.option("--ours", "strategy", flag_value="ours", help="Resolve all conflicts with local values.")
|
|
132
|
+
@click.option("--theirs", "strategy", flag_value="theirs", help="Resolve all conflicts with remote values.")
|
|
133
|
+
@pass_ctx
|
|
134
|
+
def resolve(ctx: Ctx, filename: str, strategy: str | None) -> None:
|
|
135
|
+
"""Resolve a sync conflict for a .deepcell file.
|
|
136
|
+
|
|
137
|
+
After `deepcell pull` creates a conflict (file.deepcell.local), use this
|
|
138
|
+
command to merge or pick a side. --ours and --theirs are wholesale: they
|
|
139
|
+
take that side for *every* conflicting value, so prefer the bare
|
|
140
|
+
auto-merge and resolve what it reports.
|
|
141
|
+
|
|
142
|
+
\b
|
|
143
|
+
deepcell merge resolve model.deepcell # try auto-merge
|
|
144
|
+
deepcell merge resolve model.deepcell --ours # keep local values
|
|
145
|
+
deepcell merge resolve model.deepcell --theirs # take remote values
|
|
146
|
+
"""
|
|
147
|
+
root = find_sync_root()
|
|
148
|
+
if root is None:
|
|
149
|
+
raise click.ClickException(
|
|
150
|
+
"Not inside a synced workspace. Run `deepcell clone <slug>` first."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
state = load_sync_state(root)
|
|
154
|
+
client = ctx.client
|
|
155
|
+
slug = state.workspace_slug
|
|
156
|
+
|
|
157
|
+
local_backup = root / f"{filename}.local"
|
|
158
|
+
if not local_backup.exists():
|
|
159
|
+
raise click.ClickException(
|
|
160
|
+
f"No conflict found for {filename} (no {filename}.local file). "
|
|
161
|
+
"Conflicts are created by `deepcell pull` when both sides change a file."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# .local = ours (original local), main file = theirs (remote)
|
|
165
|
+
ours_xml = local_backup.read_text(encoding="utf-8")
|
|
166
|
+
theirs_xml = (root / filename).read_text(encoding="utf-8")
|
|
167
|
+
|
|
168
|
+
# Fetch base version from last sync point
|
|
169
|
+
if not state.last_sync_sha:
|
|
170
|
+
raise click.ClickException(
|
|
171
|
+
"Cannot determine merge base: no last_sync_sha in sync state."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
base_xml = _download_file(client, slug, filename, revision=state.last_sync_sha)
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
raise click.ClickException(
|
|
178
|
+
f"Failed to fetch base version at {state.last_sync_sha[:8]}: {exc}"
|
|
179
|
+
) from exc
|
|
180
|
+
|
|
181
|
+
# Call merge preview
|
|
182
|
+
try:
|
|
183
|
+
result = client.post("/merge/preview", json={
|
|
184
|
+
"base_xml": base_xml,
|
|
185
|
+
"ours_xml": ours_xml,
|
|
186
|
+
"theirs_xml": theirs_xml,
|
|
187
|
+
"filename": filename,
|
|
188
|
+
})
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
raise click.ClickException(f"Merge API call failed: {exc}") from exc
|
|
191
|
+
|
|
192
|
+
if result.get("success") and result.get("merged_xml"):
|
|
193
|
+
# Clean merge — write result and remove .local
|
|
194
|
+
(root / filename).write_text(result["merged_xml"], encoding="utf-8")
|
|
195
|
+
_update_checksum(root, state, filename, theirs_xml)
|
|
196
|
+
auto_count = result.get("auto_resolved_count", 0)
|
|
197
|
+
if echo_merge_data_loss(result):
|
|
198
|
+
# Keep the .local backup: it is the only surviving copy of what
|
|
199
|
+
# the merge dropped.
|
|
200
|
+
echo_warning(
|
|
201
|
+
f"Merged {filename} ({auto_count} change(s) auto-resolved) but data "
|
|
202
|
+
f"was lost — '{filename}.local' kept so you can recover it"
|
|
203
|
+
)
|
|
204
|
+
raise click.exceptions.Exit(1)
|
|
205
|
+
local_backup.unlink()
|
|
206
|
+
echo_success(f"Merged {filename} ({auto_count} change(s) auto-resolved)")
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Has conflicts
|
|
210
|
+
conflicts = result.get("conflicts", [])
|
|
211
|
+
session_id = result.get("merge_session_id")
|
|
212
|
+
|
|
213
|
+
if not strategy:
|
|
214
|
+
echo_error(f"{len(conflicts)} conflict(s) in {filename}:")
|
|
215
|
+
_show_conflicts(conflicts)
|
|
216
|
+
echo_info("Resolve with --ours or --theirs, or use `deepcell merge apply`.")
|
|
217
|
+
if session_id:
|
|
218
|
+
echo_info(f"Session ID: {session_id}")
|
|
219
|
+
raise SystemExit(1)
|
|
220
|
+
|
|
221
|
+
# Resolve all conflicts with chosen strategy
|
|
222
|
+
if not session_id:
|
|
223
|
+
raise click.ClickException("No merge session ID returned. Try again.")
|
|
224
|
+
|
|
225
|
+
resolutions = {c["conflict_id"]: strategy for c in conflicts}
|
|
226
|
+
try:
|
|
227
|
+
resolved = client.post("/merge/resolve", json={
|
|
228
|
+
"merge_session_id": session_id,
|
|
229
|
+
"resolutions": resolutions,
|
|
230
|
+
})
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
raise click.ClickException(f"Merge resolve failed: {exc}") from exc
|
|
233
|
+
|
|
234
|
+
if resolved.get("success") and resolved.get("merged_xml"):
|
|
235
|
+
(root / filename).write_text(resolved["merged_xml"], encoding="utf-8")
|
|
236
|
+
_update_checksum(root, state, filename, theirs_xml)
|
|
237
|
+
if echo_merge_data_loss(resolved):
|
|
238
|
+
echo_warning(
|
|
239
|
+
f"Resolved {len(conflicts)} conflict(s) with --{strategy} but data was "
|
|
240
|
+
f"lost — '{filename}.local' kept so you can recover it"
|
|
241
|
+
)
|
|
242
|
+
raise click.exceptions.Exit(1)
|
|
243
|
+
local_backup.unlink()
|
|
244
|
+
echo_success(f"Merged {filename} (resolved {len(conflicts)} conflict(s) with --{strategy})")
|
|
245
|
+
else:
|
|
246
|
+
remaining = resolved.get("remaining_conflicts", 0)
|
|
247
|
+
echo_error(f"{remaining} conflict(s) still unresolved in {filename}")
|
|
248
|
+
_show_conflicts(resolved.get("conflicts", []))
|
|
249
|
+
raise SystemExit(1)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# -- merge preview -------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@merge.command()
|
|
256
|
+
@click.argument("filename")
|
|
257
|
+
@click.option("--base", "base_rev", default=None, help="Base revision SHA (defaults to last_sync_sha).")
|
|
258
|
+
@click.option("--theirs-rev", default=None, help="Revision for theirs (defaults to HEAD).")
|
|
259
|
+
@pass_ctx
|
|
260
|
+
def preview(ctx: Ctx, filename: str, base_rev: str | None, theirs_rev: str | None) -> None:
|
|
261
|
+
"""Preview a three-way merge for a .deepcell file.
|
|
262
|
+
|
|
263
|
+
\b
|
|
264
|
+
deepcell merge preview model.deepcell
|
|
265
|
+
deepcell merge preview model.deepcell --base abc1234
|
|
266
|
+
"""
|
|
267
|
+
root = find_sync_root()
|
|
268
|
+
if root is None:
|
|
269
|
+
raise click.ClickException("Not inside a synced workspace.")
|
|
270
|
+
|
|
271
|
+
state = load_sync_state(root)
|
|
272
|
+
client = ctx.client
|
|
273
|
+
slug = state.workspace_slug
|
|
274
|
+
|
|
275
|
+
# Ours = local file
|
|
276
|
+
ours_xml = _read_local(root, filename)
|
|
277
|
+
|
|
278
|
+
# Base = file at base_rev or last_sync_sha
|
|
279
|
+
base_sha = base_rev or state.last_sync_sha
|
|
280
|
+
if not base_sha:
|
|
281
|
+
raise click.ClickException("No base revision available. Specify --base <sha>.")
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
base_xml = _download_file(client, slug, filename, revision=base_sha)
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
raise click.ClickException(f"Failed to fetch base at {base_sha[:8]}: {exc}") from exc
|
|
287
|
+
|
|
288
|
+
# Theirs = file at theirs_rev or HEAD, on the branch you are actually on.
|
|
289
|
+
# Reading main's HEAD while a variant is checked out compares against the
|
|
290
|
+
# wrong branch: conflicts that don't exist on your branch, or a clean
|
|
291
|
+
# merge that isn't. (Base stays pinned to a SHA via the main route —
|
|
292
|
+
# commit SHAs are branch-independent and the variant route ignores
|
|
293
|
+
# `revision`.)
|
|
294
|
+
from deepcell_cli.commands.sync import _download_branch_file
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
theirs_xml = _download_branch_file(
|
|
298
|
+
client, slug, filename,
|
|
299
|
+
variant=state.active_variant, revision=theirs_rev,
|
|
300
|
+
)
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
raise click.ClickException(f"Failed to fetch theirs: {exc}") from exc
|
|
303
|
+
|
|
304
|
+
# Call merge preview
|
|
305
|
+
result = client.post("/merge/preview", json={
|
|
306
|
+
"base_xml": base_xml,
|
|
307
|
+
"ours_xml": ours_xml,
|
|
308
|
+
"theirs_xml": theirs_xml,
|
|
309
|
+
"filename": filename,
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
auto_count = result.get("auto_resolved_count", 0)
|
|
313
|
+
conflict_count = result.get("conflict_count", 0)
|
|
314
|
+
echo_info(f"Auto-resolved: {auto_count} Conflicts: {conflict_count}")
|
|
315
|
+
|
|
316
|
+
if result.get("success"):
|
|
317
|
+
lossy = echo_merge_data_loss(result)
|
|
318
|
+
echo_success("Clean merge — no conflicts.")
|
|
319
|
+
if result.get("merged_xml"):
|
|
320
|
+
click.echo(result["merged_xml"])
|
|
321
|
+
if lossy:
|
|
322
|
+
raise click.exceptions.Exit(1)
|
|
323
|
+
else:
|
|
324
|
+
conflicts = result.get("conflicts", [])
|
|
325
|
+
_show_conflicts(conflicts)
|
|
326
|
+
session_id = result.get("merge_session_id")
|
|
327
|
+
if session_id:
|
|
328
|
+
echo_info(f"Session ID: {session_id}")
|
|
329
|
+
echo_info("Resolve with: deepcell merge apply <session-id> --resolve ours|theirs")
|
|
330
|
+
# Exit nonzero so scripts can branch on "would this merge cleanly?"
|
|
331
|
+
# (consistent with `merge resolve`/`apply` and `describe --lint`).
|
|
332
|
+
raise click.exceptions.Exit(1)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# -- merge apply ---------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@merge.command()
|
|
339
|
+
@click.argument("session_id")
|
|
340
|
+
@click.option(
|
|
341
|
+
"--resolve",
|
|
342
|
+
"strategy",
|
|
343
|
+
type=click.Choice(["ours", "theirs"]),
|
|
344
|
+
required=True,
|
|
345
|
+
help="Resolution strategy for all conflicts.",
|
|
346
|
+
)
|
|
347
|
+
@click.option("--file", "filename", default=None, help="Write merged result to this local file.")
|
|
348
|
+
@pass_ctx
|
|
349
|
+
def apply(ctx: Ctx, session_id: str, strategy: str, filename: str | None) -> None:
|
|
350
|
+
"""Apply conflict resolutions to a pending merge session.
|
|
351
|
+
|
|
352
|
+
\b
|
|
353
|
+
deepcell merge apply <session-id> --resolve ours
|
|
354
|
+
deepcell merge apply <session-id> --resolve theirs --file model.deepcell
|
|
355
|
+
"""
|
|
356
|
+
client = ctx.client
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
result = client.post("/merge/resolve", json={
|
|
360
|
+
"merge_session_id": session_id,
|
|
361
|
+
"resolutions": {"*": strategy},
|
|
362
|
+
})
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
raise click.ClickException(f"Merge resolve failed: {exc}") from exc
|
|
365
|
+
|
|
366
|
+
if result.get("success") and result.get("merged_xml"):
|
|
367
|
+
lossy = echo_merge_data_loss(result)
|
|
368
|
+
if filename:
|
|
369
|
+
root = find_sync_root()
|
|
370
|
+
if root:
|
|
371
|
+
target = root / filename
|
|
372
|
+
# Read before overwriting: `pull` left `theirs` in the tracked
|
|
373
|
+
# file, and that is what the new baseline must be.
|
|
374
|
+
server_content = (
|
|
375
|
+
target.read_text(encoding="utf-8") if target.exists() else ""
|
|
376
|
+
)
|
|
377
|
+
target.write_text(result["merged_xml"], encoding="utf-8")
|
|
378
|
+
state = load_sync_state(root)
|
|
379
|
+
_update_checksum(root, state, filename, server_content)
|
|
380
|
+
# Clean up .local — unless the merge dropped data, in which
|
|
381
|
+
# case the backup is the only copy of what was lost.
|
|
382
|
+
local_backup = root / f"{filename}.local"
|
|
383
|
+
if local_backup.exists() and not lossy:
|
|
384
|
+
local_backup.unlink()
|
|
385
|
+
echo_success(f"Merged and written to {filename}")
|
|
386
|
+
else:
|
|
387
|
+
Path(filename).write_text(result["merged_xml"], encoding="utf-8")
|
|
388
|
+
echo_success(f"Merged and written to {filename}")
|
|
389
|
+
else:
|
|
390
|
+
# Output merged XML to stdout
|
|
391
|
+
click.echo(result["merged_xml"])
|
|
392
|
+
echo_success("Merge resolved successfully")
|
|
393
|
+
if lossy:
|
|
394
|
+
raise click.exceptions.Exit(1)
|
|
395
|
+
else:
|
|
396
|
+
remaining = result.get("remaining_conflicts", 0)
|
|
397
|
+
echo_error(f"{remaining} conflict(s) still unresolved")
|
|
398
|
+
_show_conflicts(result.get("conflicts", []))
|
|
399
|
+
raise SystemExit(1)
|