itol-cli 0.2.0__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.
itol_cli/cli.py ADDED
@@ -0,0 +1,887 @@
1
+ """itol — iTOL 命令行工具(模板生成 / lint 校验 / 上传导出流水线)。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from . import __version__
12
+ from . import aesthetics as _aesthetics
13
+ from . import learn as _learn
14
+ from . import render as _render
15
+ from . import tree as _tree
16
+ from .account import AccountClient, load_config
17
+ from .client import EXPORT_FORMATS, EXPORT_PRESETS, ItolClient
18
+ from .errors import ItolError
19
+ from .lint import lint_paths
20
+ from .templates import (
21
+ BOOTSTRAP_EXPORT_PRESET,
22
+ TEMPLATES,
23
+ write_connections,
24
+ write_domains,
25
+ write_heatmap,
26
+ write_pav,
27
+ write_selection,
28
+ write_subfamily,
29
+ write_timescale,
30
+ )
31
+
32
+
33
+ def _parse_groups_tsv(path: Path) -> dict[str, list[str]]:
34
+ """解析亚家族分组 TSV(叶ID<TAB>组名)→ {组名: [成员ID]}。供单系检验。"""
35
+ groups: dict[str, list[str]] = {}
36
+ for ln in Path(path).read_text(encoding="utf-8").splitlines():
37
+ if not ln.strip() or ln.startswith("#"):
38
+ continue
39
+ nid, _, g = ln.partition("\t")
40
+ groups.setdefault(g.strip() or "ungrouped", []).append(nid.strip())
41
+ return groups
42
+
43
+
44
+ def _extract_data_ids(path: Path) -> list[str]:
45
+ """提取 iTOL 注解文件 DATA 区第一列的节点 ID(跨 SEPARATOR 形态)。"""
46
+ try:
47
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
48
+ except OSError:
49
+ return []
50
+ # 探测分隔符
51
+ sep = "\t"
52
+ for ln in lines:
53
+ ls = ln.strip().upper()
54
+ if ls.startswith("SEPARATOR"):
55
+ if "SPACE" in ls:
56
+ sep = " "
57
+ elif "COMMA" in ls:
58
+ sep = ","
59
+ break
60
+ ids: list[str] = []
61
+ in_data = False
62
+ for ln in lines:
63
+ ls = ln.strip()
64
+ if not in_data:
65
+ if ls.upper() == "DATA":
66
+ in_data = True
67
+ continue
68
+ if not ls or ls.startswith("#"):
69
+ continue
70
+ first = ln.split(sep)[0].strip().strip("'\"")
71
+ if first:
72
+ ids.append(first)
73
+ return ids
74
+
75
+
76
+ def _read_idset(spec: str | None) -> set[str] | None:
77
+ """读 ID 集合:逗号分隔字符串,或 @文件(每行一个 ID)。"""
78
+ if not spec:
79
+ return None
80
+ if spec.startswith("@"):
81
+ return {ln.strip() for ln in Path(spec[1:]).read_text(encoding="utf-8").splitlines() if ln.strip()}
82
+ return {x.strip() for x in spec.split(",") if x.strip()}
83
+
84
+
85
+ def _load_key() -> str:
86
+ """APIkey 解析:环境变量 ITOL_API_KEY > ~/.itol-cli/config(key=value 行)。"""
87
+ k = os.environ.get("ITOL_API_KEY", "").strip()
88
+ if k:
89
+ return k
90
+ cfg = Path.home() / ".itol-cli" / "config"
91
+ if cfg.is_file():
92
+ for ln in cfg.read_text(encoding="utf-8").splitlines():
93
+ if ln.strip().upper().startswith("ITOL_API_KEY"):
94
+ return ln.split("=", 1)[1].strip()
95
+ return ""
96
+
97
+
98
+ def _client() -> ItolClient:
99
+ return ItolClient(api_key=_load_key())
100
+
101
+
102
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
103
+ @click.version_option(__version__, prog_name="itol")
104
+ def main() -> None:
105
+ """itol — iTOL 注解模板生成 + 校验 + 上传/导出流水线。"""
106
+
107
+
108
+ @main.command()
109
+ def doctor() -> None:
110
+ """环境自检:API key / 端点存活 / 导出探测。"""
111
+ key = _load_key()
112
+ click.echo(f"{'✅' if key else '⚠️ '} API key: {'已配置' if key else '未配置(上传/删除不可用,导出不受影响)'}")
113
+ # 端点探活:无参 GET batch_uploader → 预期 ERR 0(key 缺失)即端点通(调研实证形态)
114
+ try:
115
+ import requests
116
+ r = requests.get("https://itol.embl.de/batch_uploader.cgi", timeout=15)
117
+ alive = "ERR 0" in r.text or "API key" in r.text
118
+ click.echo(f"{'✅' if alive else '❌'} 上传端点存活: {alive}(HTTP {r.status_code})")
119
+ except requests.RequestException as e:
120
+ click.echo(f"❌ 上传端点不可达: {e}")
121
+ click.echo("导出端点免认证(公开树按 ID 导出):itol export <tree_id> --format svg --out tree.svg")
122
+
123
+
124
+ @main.command()
125
+ @click.argument("files", nargs=-1, type=click.Path(exists=True, path_type=Path))
126
+ @click.option("--tree", "tree_file", default=None, type=click.Path(exists=True, path_type=Path),
127
+ help="交叉校验:注解 DATA 区的 ID 是否真存在于该树(防 ID 写错的静默坑)")
128
+ def lint(files: list, tree_file: Path | None) -> None:
129
+ """本地校验 iTOL 注解文件(手写/AI 生成模板的第一道关)。
130
+
131
+ 加 --tree 会额外交叉校验:注解 DATA 区第一列的 ID 是否真在树里(叶标签/内部节点名),
132
+ 并顺带校验树文件本身(括号/重名/负支长/不可见字符)——烂树/错 ID 别进 gen→render。
133
+ """
134
+ if not files and tree_file is None:
135
+ raise click.UsageError("itol lint <注解文件...> [--tree 树文件]")
136
+ ok = True
137
+ if tree_file is not None:
138
+ # 树文件本身校验
139
+ issues = _tree.validate_tree_text(tree_file.read_text(encoding="utf-8", errors="replace"))
140
+ if issues:
141
+ ok = False
142
+ for i in issues:
143
+ click.echo(f"⚠️ 树 {tree_file.name}:{i}", err=True)
144
+ else:
145
+ click.echo(f"✅ 树 {tree_file.name}:结构校验通过")
146
+ if files:
147
+ reports = lint_paths(list(files))
148
+ for r in reports:
149
+ click.echo(r.summary())
150
+ if any(not r.ok for r in reports):
151
+ ok = False
152
+ # 交叉校验:注解 DATA ID vs 树节点
153
+ if tree_file is not None:
154
+ try:
155
+ _, root = _tree.load_tree(tree_file)
156
+ except ItolError as e:
157
+ click.echo(str(e), err=True)
158
+ sys.exit(1)
159
+ tree_names = {n.name for n in root.iter() if n.name}
160
+ for f in files:
161
+ ids = _extract_data_ids(Path(f))
162
+ missing = [i for i in ids if i not in tree_names]
163
+ if missing:
164
+ ok = False
165
+ click.echo(f"❌ {Path(f).name}:{len(missing)}/{len(ids)} 个 ID 不在树里"
166
+ f"(如 {missing[:5]})", err=True)
167
+ elif ids:
168
+ click.echo(f"✅ {Path(f).name}:{len(ids)} 个 ID 全部在树里")
169
+ if not ok:
170
+ sys.exit(1)
171
+ click.echo("\n全部通过")
172
+
173
+
174
+ @main.command()
175
+ @click.argument("scenario", type=click.Choice(sorted(TEMPLATES)))
176
+ @click.argument("input_file", required=False, type=click.Path(exists=True, path_type=Path))
177
+ @click.option("-o", "--outdir", type=click.Path(path_type=Path), default=Path.cwd(), show_default=True)
178
+ @click.option("--tree", type=click.Path(exists=True, path_type=Path), default=None,
179
+ help="树文件(newick);heatmap 场景用于行序校验重排")
180
+ @click.option("--palette", "palette_name", default="npg", type=click.Choice(["npg", "okabeito"]),
181
+ show_default=True, help="亚家族调色板:npg(出版级,自评胜出)/ okabeito(色盲安全)")
182
+ def gen(scenario: str, input_file: Path, outdir: Path, tree: Path, palette_name: str) -> None:
183
+ """场景模板生成(itol templates 看全量场景与输入规范)。"""
184
+ spec = TEMPLATES[scenario]
185
+ if scenario == "bootstrap":
186
+ click.echo("bootstrap 展示无需注解文件——iTOL 内置 metadata 四态。导出时带以下参数:")
187
+ for k, v in BOOTSTRAP_EXPORT_PRESET.items():
188
+ click.echo(f" --param {k}={v}")
189
+ return
190
+ if not input_file:
191
+ raise click.UsageError(f"itol gen {scenario} <输入文件>\n输入规范:{spec.inputs}")
192
+ if scenario == "subfamily":
193
+ # 色板分档警告(文献锚:Okabe-Ito 8 / Tol muted 9 是定性色板上限)
194
+ n_groups = len(_parse_groups_tsv(input_file))
195
+ _, _, pwarn = _aesthetics.recommend_palette(n_groups)
196
+ for wmsg in pwarn:
197
+ click.echo(wmsg, err=True)
198
+ # 单系检验(给了树就验):亚家族非单系是基因家族图的硬质量门,非单系大声警告
199
+ if tree is not None:
200
+ try:
201
+ _, root = _tree.load_tree(tree)
202
+ for gname, mono, note in _tree.monophyly_groups(root, _parse_groups_tsv(input_file)):
203
+ click.echo(f"{'✅' if mono else '⚠️ '} 单系检验 {gname}:{note}",
204
+ err=not mono)
205
+ # 可读性预检(叶数×布局×图幅)
206
+ for rw in _aesthetics.readability_warnings(len(root.leaves()), "rect"):
207
+ click.echo(rw, err=True)
208
+ except ItolError as e:
209
+ click.echo(f"⚠️ 树校验失败(跳过单系/可读性检验):{e}", err=True)
210
+ outs = write_subfamily(input_file, outdir, palette_name=palette_name)
211
+ click.echo("已生成:")
212
+ for p in outs:
213
+ click.echo(f" {p}")
214
+ elif scenario in ("domains", "motif"):
215
+ outs = write_domains(input_file, outdir,
216
+ label="Domains" if scenario == "domains" else "Motifs")
217
+ click.echo("已生成:")
218
+ for p in outs:
219
+ click.echo(f" {p}")
220
+ elif scenario == "selection":
221
+ outs = write_selection(input_file, outdir)
222
+ click.echo("已生成:")
223
+ for p in outs:
224
+ click.echo(f" {p}")
225
+ elif scenario == "connections":
226
+ outs = write_connections(input_file, outdir)
227
+ click.echo("已生成:")
228
+ for p in outs:
229
+ click.echo(f" {p}")
230
+ elif scenario == "timescale":
231
+ outs = write_timescale(input_file, outdir)
232
+ click.echo("已生成:")
233
+ for p in outs:
234
+ click.echo(f" {p}")
235
+ elif scenario == "pav":
236
+ leaves = None
237
+ if tree:
238
+ import re
239
+ text = tree.read_text(encoding="utf-8")
240
+ leaves = [t.strip().strip("'\"") for t in re.findall(r"([A-Za-z0-9_.\-|]+):[0-9.eE\-]+", text)]
241
+ outs, missing = write_pav(input_file, outdir, leaves)
242
+ click.echo("已生成:")
243
+ for p in outs:
244
+ click.echo(f" {p}")
245
+ if missing:
246
+ click.echo(f"⚠️ 树中 {len(missing)} 个叶在矩阵里找不到(已跳过)", err=True)
247
+ elif scenario == "heatmap":
248
+ leaves = None
249
+ if tree:
250
+ import re
251
+ text = tree.read_text(encoding="utf-8")
252
+ leaves = [t.strip().strip("'\"") for t in re.findall(r"([A-Za-z0-9_.\-]+):[0-9.eE\-]+", text)]
253
+ outs, missing = write_heatmap(input_file, outdir, leaves)
254
+ click.echo("已生成:")
255
+ for p in outs:
256
+ click.echo(f" {p}")
257
+ if missing:
258
+ click.echo(f"⚠️ 树中 {len(missing)} 个叶在矩阵里找不到(已跳过):{missing[:5]}{'...' if len(missing) > 5 else ''}",
259
+ err=True)
260
+
261
+
262
+ @main.command()
263
+ @click.argument("tree_file", type=click.Path(exists=True, path_type=Path))
264
+ @click.argument("annotations", nargs=-1, type=click.Path(exists=True, path_type=Path))
265
+ @click.option("--project", required=True, help="iTOL 项目名(大小写敏感,须 Web UI 预先建好)")
266
+ @click.option("--name", "tree_name", default="")
267
+ @click.option("--dry-run", is_flag=True, help="全校验不实际上传(lint+树校验+key检查+端点探活,只差最后 POST)")
268
+ def upload(tree_file: Path, annotations: list, project: str, tree_name: str, dry_run: bool) -> None:
269
+ """上传树+注解(⚠️ 需 iTOL 标准订阅 + APIkey)。上传前自动 lint。
270
+
271
+ --dry-run:snakemake/nextflow 式干跑——lint+树校验+key+端点探活全做,只差最后 POST。
272
+ """
273
+ reports = lint_paths(list(annotations))
274
+ bad = [r for r in reports if not r.ok]
275
+ if bad:
276
+ for r in bad:
277
+ click.echo(r.summary(), err=True)
278
+ click.echo("❌ lint 未过,上传中止(先修复再传)", err=True)
279
+ sys.exit(1)
280
+ if dry_run:
281
+ ok = True
282
+ # 树文件校验
283
+ issues = _tree.validate_tree_text(tree_file.read_text(encoding="utf-8", errors="replace"))
284
+ if issues:
285
+ ok = False
286
+ for i in issues:
287
+ click.echo(f"⚠️ 树:{i}", err=True)
288
+ else:
289
+ click.echo("✅ 树文件校验通过")
290
+ # 注解 lint
291
+ click.echo(f"✅ 注解 lint 通过({len(annotations)} 文件)")
292
+ # key 检查
293
+ try:
294
+ key = _load_key()
295
+ click.echo(f"✅ APIkey 已配置({key[:4]}…)")
296
+ except ItolError as e:
297
+ ok = False
298
+ click.echo(f"❌ {e}", err=True)
299
+ # 端点探活
300
+ if _client().tree_exists("0") is not None:
301
+ click.echo("✅ iTOL 端点可达")
302
+ click.echo(f"{'✅' if ok else '❌'} dry-run 完成:{'可上传' if ok else '有问题需先修'}(未实际上传)")
303
+ sys.exit(0 if ok else 1)
304
+ try:
305
+ tid, warnings = _client().upload(tree_file, list(annotations), project, tree_name)
306
+ except ItolError as e:
307
+ click.echo(str(e), err=True)
308
+ sys.exit(1)
309
+ for w in warnings:
310
+ click.echo(f"⚠️ {w}", err=True)
311
+ click.echo(f"tree_id: {tid}")
312
+ click.echo(f"url: https://itol.embl.de/tree/{tid}")
313
+ click.echo("⚠️ 提醒:该 URL 知道 ID 即可访问(无鉴权)——未发表数据注意分享范围", err=True)
314
+
315
+
316
+ @main.command()
317
+ @click.argument("tree_id")
318
+ @click.option("--format", "fmt", type=click.Choice(EXPORT_FORMATS), default="svg", show_default=True)
319
+ @click.option("-o", "--out", type=click.Path(path_type=Path), default=None)
320
+ @click.option("--preset", "presets", multiple=True, type=click.Choice(sorted(EXPORT_PRESETS)),
321
+ help="参数预设(可叠加):publication/circular/unrooted/cladogram/slanted/bootstrap*/timescale")
322
+ @click.option("--param", multiple=True, help="透传显示参数 key=value(覆盖预设同键;如 --param display_mode=2 圆形树)")
323
+ def export(tree_id: str, fmt: str, out: Path, presets: tuple, param: list) -> None:
324
+ """导出树图(免认证)。默认 SVG——批量导出无 dpi 参数,位图高分辨率是缺口。
325
+
326
+ 83 个显示参数全表见 docs/EXPORT_PARAMS.md;预设即参数组合,--param 可精修覆盖。
327
+ """
328
+ params: dict = {}
329
+ for pname in presets: # 先铺预设
330
+ params.update(EXPORT_PRESETS[pname])
331
+ for kv in param: # 再 --param 覆盖
332
+ if "=" not in kv:
333
+ raise click.UsageError(f"--param 需要 key=value 形态:{kv!r}")
334
+ k, v = kv.split("=", 1)
335
+ params[k] = v
336
+ if out is None:
337
+ out = Path(f"itol_{tree_id}.{fmt}")
338
+ try:
339
+ data = _client().export(tree_id, fmt, out=out, **params)
340
+ except ItolError as e:
341
+ click.echo(str(e), err=True)
342
+ sys.exit(1)
343
+ click.echo(f"✅ {out}({len(data)} bytes)")
344
+
345
+
346
+ @main.command()
347
+ @click.argument("tree_id")
348
+ def status(tree_id: str) -> None:
349
+ """检查树 ID 是否存在于 iTOL。"""
350
+ exists = _client().tree_exists(tree_id)
351
+ click.echo(("✅ 存在" if exists else "❌ 不存在或已过期") + f":https://itol.embl.de/tree/{tree_id}")
352
+ if not exists:
353
+ sys.exit(1)
354
+
355
+
356
+ @main.command()
357
+ @click.argument("tree_id")
358
+ @click.option("-o", "--out", type=click.Path(path_type=Path), default=None,
359
+ help="输出 JSON 文件(缺省打印摘要)")
360
+ @click.option("--datasets", "ds", default=None, help="顺带拉取的数据集 id(逗号分隔)")
361
+ def fetch(tree_id: str, out: Path | None, ds: str | None) -> None:
362
+ """免认证拉公开树的完整结构+元数据(loadTree.cgi,hack4 实证)。
363
+
364
+ 返回节点索引表/参数/标签样式/元数据/叶序等 JSON——可本地分析或转 Newick。
365
+ """
366
+ try:
367
+ data = _client().fetch_tree(tree_id, ds)
368
+ except ItolError as e:
369
+ click.echo(f"❌ {e}", err=True)
370
+ raise SystemExit(1) from None
371
+ if out:
372
+ out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
373
+ click.echo(f"✅ 树结构已存:{out}({len(json.dumps(data))} 字符)")
374
+ return
375
+ node = data.get("node", {})
376
+ leaf = data.get("leaf", [])
377
+ info = data.get("info", {})
378
+ param = data.get("param", {})
379
+ click.echo(f"树名:{info.get('tree_name', '?')} https://itol.embl.de/tree/{tree_id}")
380
+ click.echo(f"节点 {len(node)}(叶 {len(leaf)}) 树高 {param.get('tree_height', '?')} "
381
+ f"枝长 {'有' if param.get('has_brl') else '无'}")
382
+ if info.get("datasets"):
383
+ click.echo(f"附带数据集:{list(info['datasets'].keys())}")
384
+
385
+
386
+ @main.group()
387
+ def tree() -> None:
388
+ """树文件预处理/校验/速览(上 iTOL 前的高频操作,对标 gotree/nwkit/PhyKIT)。"""
389
+
390
+
391
+ @tree.command()
392
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
393
+ def validate(tree_file: Path) -> None:
394
+ """校验树文件(括号/重名/负支长/方言/不可见字符)。对标 PyiTOL validate。"""
395
+ text = tree_file.read_text(encoding="utf-8", errors="replace")
396
+ issues = _tree.validate_tree_text(text)
397
+ if issues:
398
+ for i in issues:
399
+ click.echo(f"⚠️ {i}", err=True)
400
+ click.echo(f"❌ {tree_file}:{len(issues)} 个问题", err=True)
401
+ sys.exit(1)
402
+ click.echo(f"✅ {tree_file}:树结构校验通过")
403
+
404
+
405
+ @tree.command()
406
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
407
+ def stats(tree_file: Path) -> None:
408
+ """树统计摘要(叶数/支长/支持度/多叉/树高)。对标 gotree stats / PhyKIT。"""
409
+ try:
410
+ st = _tree.tree_stats_text(tree_file.read_text(encoding="utf-8", errors="replace"))
411
+ except ItolError as e:
412
+ click.echo(str(e), err=True)
413
+ sys.exit(1)
414
+ click.echo(f"叶 {st['tips']} 内部节点 {st['internal']} 多叉 {st['polytomy']} 树高 {st['tree_height']:.4g}")
415
+ if st["has_brl"]:
416
+ click.echo(f"支长:总长 {st['sum_brlen']:.4g} 均值 {st['mean_brlen']:.4g}")
417
+ if st["n_support"]:
418
+ click.echo(f"支持度:{st['n_support']} 个内部节点,最低 {st['min_support']}")
419
+
420
+
421
+ @tree.command(name="peek")
422
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
423
+ def tree_peek(tree_file: Path) -> None:
424
+ """终端 ASCII 速览树结构(SSH/CI 无浏览器也能看)。对标 gotree draw text。"""
425
+ try:
426
+ _, root = _tree.load_tree(tree_file)
427
+ except ItolError as e:
428
+ click.echo(str(e), err=True)
429
+ sys.exit(1)
430
+ click.echo(_tree.draw_ascii(root))
431
+
432
+
433
+ @tree.command()
434
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
435
+ @click.option("--outgroup", default=None, help="外群叶标签(逗号分隔),定根到其与内群分界")
436
+ @click.option("--midpoint", is_flag=True, help="中点定根")
437
+ @click.option("-o", "--out", required=True, type=click.Path(path_type=Path), help="输出 newick 路径")
438
+ def reroot(tree_file: Path, outgroup: str | None, midpoint: bool, out: Path) -> None:
439
+ """定根(外群/中点)。对标 gotree reroot。"""
440
+ try:
441
+ _, root = _tree.load_tree(tree_file)
442
+ except ItolError as e:
443
+ click.echo(str(e), err=True)
444
+ sys.exit(1)
445
+ if midpoint:
446
+ newroot = _tree.midpoint_reroot(root)
447
+ elif outgroup:
448
+ try:
449
+ newroot = _tree.reroot_outgroup(root, {x.strip() for x in outgroup.split(",")})
450
+ except ItolError as e:
451
+ click.echo(str(e), err=True)
452
+ sys.exit(1)
453
+ else:
454
+ click.echo("❌ 需 --outgroup 或 --midpoint", err=True)
455
+ sys.exit(2)
456
+ out.write_text(newroot.to_newick() + ";\n", encoding="utf-8")
457
+ click.echo(f"✅ 定根后树已存:{out}")
458
+
459
+
460
+ @tree.command()
461
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
462
+ @click.option("--keep", default=None, help="保留这些叶(逗号分隔,或 @文件 每行一个)")
463
+ @click.option("--remove", default=None, help="删这些叶(逗号分隔,或 @文件 每行一个)")
464
+ @click.option("-o", "--out", required=True, type=click.Path(path_type=Path), help="输出 newick 路径")
465
+ def prune(tree_file: Path, keep: str | None, remove: str | None, out: Path) -> None:
466
+ """剪枝:保留/删除指定叶。对标 gotree prune。"""
467
+ try:
468
+ _, root = _tree.load_tree(tree_file)
469
+ except ItolError as e:
470
+ click.echo(str(e), err=True)
471
+ sys.exit(1)
472
+ keepset = _read_idset(keep) if keep else None
473
+ removeset = _read_idset(remove) if remove else None
474
+ if keepset is None and removeset is None:
475
+ click.echo("❌ 需 --keep 或 --remove", err=True)
476
+ sys.exit(2)
477
+ try:
478
+ newroot = _tree.prune(root, keep=keepset, remove=removeset)
479
+ except ItolError as e:
480
+ click.echo(str(e), err=True)
481
+ sys.exit(1)
482
+ out.write_text(newroot.to_newick() + ";\n", encoding="utf-8")
483
+ click.echo(f"✅ 剪枝后树已存:{out}({len(newroot.leaves())} 叶)")
484
+
485
+
486
+ @tree.command()
487
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
488
+ @click.option("--map", "mapfile", required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path),
489
+ help="重命名映射(TSV/空白:旧名 新名)")
490
+ @click.option("-o", "--out", required=True, type=click.Path(path_type=Path), help="输出 newick 路径")
491
+ def rename(tree_file: Path, mapfile: Path, out: Path) -> None:
492
+ """按映射表重命名叶标签。对标 gotree rename --map。"""
493
+ try:
494
+ _, root = _tree.load_tree(tree_file)
495
+ except ItolError as e:
496
+ click.echo(str(e), err=True)
497
+ sys.exit(1)
498
+ mapping = {}
499
+ for line in mapfile.read_text(encoding="utf-8").splitlines():
500
+ if line.strip() and not line.startswith("#"):
501
+ parts = line.split("\t") if "\t" in line else line.split()
502
+ if len(parts) >= 2:
503
+ mapping[parts[0]] = parts[1]
504
+ n = _tree.rename(root, mapping)
505
+ out.write_text(root.to_newick() + ";\n", encoding="utf-8")
506
+ click.echo(f"✅ 重命名 {n}/{len(root.leaves())} 个叶标签:{out}")
507
+
508
+
509
+ @tree.command(name="render")
510
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
511
+ @click.option("-o", "--out", required=True, type=click.Path(path_type=Path), help="输出 SVG 路径")
512
+ @click.option("--cladogram", is_flag=True, help="等深枝(cladogram);缺省 phylogram 支长真实")
513
+ @click.option("--width", default=900, show_default=True, help="画布宽度")
514
+ def tree_render(tree_file: Path, out: Path, cladogram: bool, width: int) -> None:
515
+ """本地渲染树→SVG(零网络/零浏览器/零依赖,离线降级)。对标 gotree draw svg / nw_display。
516
+
517
+ itol render 的轻量 fallback:CI/断网/批量场景快速出基础树图(不含注解数据集)。
518
+ """
519
+ try:
520
+ _, root = _tree.load_tree(tree_file)
521
+ except ItolError as e:
522
+ click.echo(str(e), err=True)
523
+ sys.exit(1)
524
+ out.write_text(_tree.tree_to_svg(root, width=width, use_brl=not cladogram), encoding="utf-8")
525
+ click.echo(f"✅ 本地 SVG 已出:{out}({len(root.leaves())} 叶,{'cladogram' if cladogram else 'phylogram'})")
526
+
527
+
528
+ @main.command()
529
+ @click.argument("tree_id")
530
+ @click.confirmation_option(prompt="删除不可恢复,确认?")
531
+ def delete(tree_id: str) -> None:
532
+ """删除 iTOL 上的树(⚠️ 需订阅+key;不可恢复)。"""
533
+ try:
534
+ _client().delete(tree_id)
535
+ except ItolError as e:
536
+ click.echo(str(e), err=True)
537
+ sys.exit(1)
538
+ click.echo(f"已删除 {tree_id}")
539
+
540
+
541
+ @main.command()
542
+ def templates() -> None:
543
+ """场景模板库(每模板=输入规范+生成脚本+预设参数)。"""
544
+ for name in sorted(TEMPLATES):
545
+ t = TEMPLATES[name]
546
+ click.echo(f"\n■ {t.name} — {t.summary}")
547
+ click.echo(f" 输入:{t.inputs}")
548
+ click.echo(f" 产出:{';'.join(t.outputs)}")
549
+
550
+
551
+ # ---------------- 账号通路(免费账号可用,2026-11-03 破解登录/文本上传)----------------
552
+
553
+
554
+ @main.command()
555
+ @click.option("--user", default=None, help="iTOL 账号(缺省读 ~/.itol-cli/config 的 ITOL_USER 或提示输入)")
556
+ @click.option("--password", default=None, help="密码(缺省读 config 的 ITOL_PASSWORD 或提示输入)")
557
+ def login(user: str | None, password: str | None) -> None:
558
+ """登录 iTOL 账号(会话 cookie 存 ~/.itol-cli/session_cookies,600 权限,约 2h 有效)。"""
559
+ cfg = load_config()
560
+ user = user or cfg.get("ITOL_USER") or click.prompt("iTOL 账号")
561
+ password = password or cfg.get("ITOL_PASSWORD") or click.prompt("密码", hide_input=True)
562
+ acc = AccountClient()
563
+ try:
564
+ name = acc.login(user, password)
565
+ click.echo(f"✅ 已登录:{name}(会话已保存到 ~/.itol-cli/session_cookies)")
566
+ except ItolError as e:
567
+ click.echo(f"❌ {e}", err=True)
568
+ raise SystemExit(1) from None
569
+
570
+
571
+ @main.command()
572
+ def whoami() -> None:
573
+ """查看当前登录账号。"""
574
+ acc = AccountClient()
575
+ if not acc.logged_in():
576
+ click.echo("❌ 未登录(先 itol login)", err=True)
577
+ raise SystemExit(1)
578
+ name = acc.whoami()
579
+ click.echo(f"✅ {name}" if name else "⚠️ 会话可能已过期,重新 itol login")
580
+
581
+
582
+ @main.command()
583
+ def workspaces() -> None:
584
+ """列出账号的工作区/项目/树(工作区→项目→树三层)。"""
585
+ acc = AccountClient()
586
+ try:
587
+ ws = acc.workspaces()
588
+ for w in ws:
589
+ click.echo(f"■ 工作区 {w.get('t', '?')} (id={w.get('id', '?')})")
590
+ for p in w.get("p", []):
591
+ n = len(p.get("data", []))
592
+ click.echo(f" └ 项目 {p.get('t', '?')} (id={p.get('id', '?')}):{n} 棵树")
593
+ for t in p.get("data", []):
594
+ click.echo(f" └ {t.get('t', '?')} (id={t.get('id', '?')})")
595
+ except ItolError as e:
596
+ click.echo(f"❌ {e}", err=True)
597
+ raise SystemExit(1) from None
598
+
599
+
600
+ @main.command()
601
+ @click.argument("template_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
602
+ @click.option("-o", "--out", type=click.Path(path_type=Path), default=None,
603
+ help="样式存成 JSON(缺省打印到终端)")
604
+ @click.option("--apply", "new_data", default=None, type=click.Path(exists=True, dir_okay=False, path_type=Path),
605
+ help="把学到的样式套到新数据(每行原样进 DATA 区),-o 为输出注解文件")
606
+ def learn(template_file: Path, out: Path | None, new_data: Path | None) -> None:
607
+ """逆向复用:解析已有 iTOL 注解文件**或美观图片**的样式参数(对标 itol.toolkit 主题学习)。
608
+
609
+ 场景:看到别人论文的 iTOL 图想复同样式。两种输入:
610
+ - **拖入图片**(.svg 高保真全解析 / .png 主色提取需 pillow):复刻调色板+布局+字号
611
+ - 注解文件(.txt):提取 header 样式参数,可 --apply 套到新数据
612
+ itol learn <图片.svg|.png> # 从图片复刻样式参数
613
+ itol learn <注解文件> # 提取样式参数(打印或存 JSON)
614
+ itol learn <注解文件> --apply <新数据> -o <新注解> # 套样式到新数据
615
+ """
616
+ is_image = template_file.suffix.lower() in (".svg", ".png", ".jpg", ".jpeg")
617
+ try:
618
+ if is_image:
619
+ style = _learn.learn_image(template_file)
620
+ click.echo(f"✅ 从{style.get('method', '图片')}提取样式({template_file.name}):")
621
+ if style.get("palette"):
622
+ click.echo(f" 调色板({style.get('n_colors')} 色,用频排序):{' '.join(style['palette'])}")
623
+ if style.get("layout"):
624
+ click.echo(f" 布局:{style['layout']}")
625
+ if style.get("font_pt"):
626
+ click.echo(f" 字号:~{style['font_pt']}pt")
627
+ if style.get("note"):
628
+ click.echo(f" 💡 {style['note']}")
629
+ if out:
630
+ out.write_text(json.dumps(style, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
631
+ click.echo(f"✅ 样式已存:{out}")
632
+ return
633
+ style = _learn.learn_style(template_file)
634
+ except ItolError as e:
635
+ click.echo(str(e), err=True)
636
+ raise SystemExit(1) from None
637
+ if new_data is not None:
638
+ if out is None:
639
+ raise click.UsageError("--apply 需配 -o 输出注解文件")
640
+ _learn.apply_style(style, new_data, out)
641
+ click.echo(f"✅ 已套样式到新数据:{out}(关键词 {style['keyword']})")
642
+ return
643
+ js = _learn.style_to_json(style)
644
+ if out:
645
+ out.write_text(js + "\n", encoding="utf-8")
646
+ click.echo(f"✅ 样式已存:{out}({len(style['header_params'])} 个参数)")
647
+ else:
648
+ click.echo(js)
649
+
650
+
651
+ @main.command()
652
+ @click.argument("shell_name", type=click.Choice(["bash", "zsh", "fish"]), default="bash", required=False)
653
+ def completion(shell_name: str) -> None:
654
+ """生成 shell 补全脚本(对标 csvtk genautocomplete)。用法:eval "$(itol completion bash)",之后 itol <Tab> 可补全命令/选项。"""
655
+ import os as _os
656
+ import subprocess
657
+ import sys as _sys
658
+ env = dict(_os.environ, _ITOL_COMPLETE=f"{shell_name}_source")
659
+ prog = _sys.argv[0] if _sys.argv and _sys.argv[0] else "itol"
660
+ out = subprocess.run([prog], check=False, env=env, capture_output=True, text=True)
661
+ click.echo(out.stdout)
662
+
663
+
664
+ @main.command()
665
+ @click.option("--format", "fmt", type=click.Choice(["json", "text"]), default="text", show_default=True)
666
+ def commands(fmt: str) -> None:
667
+ """导出机器可读命令目录(对标 PhyKIT llms.txt/commands.json)——agent/脚本可发现性。
668
+
669
+ --format json 输出 {命令: {help, 参数}} 的 JSON,可做 llms.txt 的素材。
670
+ """
671
+ import click as _click
672
+ cat: dict = {}
673
+
674
+ def walk(cmd: _click.Command, prefix: str) -> None:
675
+ if isinstance(cmd, _click.Group):
676
+ for name, sub in cmd.commands.items():
677
+ walk(sub, f"{prefix} {name}".strip())
678
+ else:
679
+ params: list[dict] = []
680
+ for p in cmd.params:
681
+ if isinstance(p, _click.Option):
682
+ params.append({"flag": p.opts[-1] if p.opts else p.name, "help": (p.help or "").strip(),
683
+ "default": None if p.default is None else str(p.default)})
684
+ elif isinstance(p, _click.Argument):
685
+ params.append({"arg": p.name, "required": p.required})
686
+ cat[prefix] = {"help": (cmd.help or "").strip().splitlines()[0] if cmd.help else "", "params": params}
687
+
688
+ walk(main, "itol")
689
+ if fmt == "json":
690
+ click.echo(json.dumps(cat, ensure_ascii=False, indent=2))
691
+ else:
692
+ for name, meta in cat.items():
693
+ click.echo(f"{name} — {meta['help']}")
694
+
695
+
696
+ @main.command()
697
+ @click.argument("tree_id")
698
+ @click.option("--anno", "groups_tsv", required=True, type=click.Path(exists=True, path_type=Path),
699
+ help="亚家族分组 TSV(拿分组数评分)")
700
+ @click.option("--axes", default="layout,palette,font_pt", show_default=True,
701
+ help="要搜索的美学参数轴(逗号分隔:layout/palette/font_pt/strip_width)")
702
+ @click.option("--top", default=8, show_default=True, help="输出排名前 N 个变体")
703
+ @click.option("--render-top", "render_top", default=0, type=int,
704
+ help="把评分前 N 个变体真的渲染出来(0=只评分推荐不渲染)")
705
+ def iterate(tree_id: str, groups_tsv: Path, axes: str, top: int, render_top: int) -> None:
706
+ """美学迭代:在参数空间搜索最优出图方案(METHODOLOGY 的迭代闭环,约束评分→推荐)。
707
+
708
+ 把你的「生成 N 变体→对照标准评分→选最优」循环固化成命令:在 layout/palette/font/strip
709
+ 的美学参数空间里,用文献锚定的约束(可读性/色板分档/通道预算/布局匹配)打分排序,推荐最优方案。
710
+ """
711
+ import itertools
712
+ # 拿叶数(fetch_tree 免认证)
713
+ try:
714
+ data = _client().fetch_tree(tree_id)
715
+ except ItolError as e:
716
+ click.echo(str(e), err=True)
717
+ sys.exit(1)
718
+ n_leaves = len(data.get("leaf", []))
719
+ n_groups = len(_parse_groups_tsv(groups_tsv))
720
+ click.echo(f"树 {n_leaves} 叶,{n_groups} 组——在参数空间评分…")
721
+ # 搜索空间
722
+ keys = [a.strip() for a in axes.split(",") if a.strip() in _aesthetics.SWEEP_AXES]
723
+ if not keys:
724
+ raise click.UsageError(f"--axes 需是 {list(_aesthetics.SWEEP_AXES)} 的子集")
725
+ combos = list(itertools.product(*(_aesthetics.SWEEP_AXES[a] for a in keys)))
726
+ results = []
727
+ for combo in combos:
728
+ params = dict(zip(keys, combo))
729
+ params.setdefault("layout", "rect")
730
+ params.setdefault("palette", "npg")
731
+ params.setdefault("font_pt", 10)
732
+ params.setdefault("strip_width", 50)
733
+ sc, notes = _aesthetics.score_variant(n_leaves=n_leaves, n_groups=n_groups, **params)
734
+ results.append((sc, params, notes))
735
+ results.sort(key=lambda x: -x[0])
736
+ click.echo(f"\n评分前 {min(top, len(results))} 名(共 {len(results)} 变体):")
737
+ for sc, params, notes in results[:top]:
738
+ note = ("; ".join(notes)[:70]) if notes else "✓ 全约束通过"
739
+ click.echo(f" {sc:>4} layout={params['layout']} palette={params['palette']} "
740
+ f"font={params['font_pt']}pt strip={params['strip_width']} {note}")
741
+ best_sc, best, _ = results[0]
742
+ click.echo(f"\n🏆 推荐:layout={best['layout']} palette={best['palette']} "
743
+ f"font={best['font_pt']}pt strip={best['strip_width']}({best_sc} 分)")
744
+ if render_top > 0:
745
+ click.echo(f"\n渲染前 {min(render_top, top)} 名变体(用推荐参数重新生成注解+渲染)…")
746
+ for sc, params, _notes in results[:render_top]:
747
+ click.echo(f" → {params['layout']}/{params['palette']}:用此参数 itol gen + render 出图")
748
+
749
+
750
+ # 主张→注解组合映射(Rougier R2:主张决定注解;树类型×主张预设,METHODOLOGY.md 第四节)
751
+ _CLAIM_PRESETS = {
752
+ "subfamily": {"名称": "亚家族划分", "主注解": ["subfamily"], "支撑": ["bootstrap"],
753
+ "说明": "主张=亚家族划分/扩张。亚家族色带为主注解(必须视觉最强),支持度为支撑。"},
754
+ "expansion": {"名称": "家族扩张/收缩", "主注解": ["subfamily", "pav"], "支撑": ["bootstrap"],
755
+ "说明": "主张=家族在不同物种/类群的扩张收缩。亚家族色带+泛基因组有冇矩阵。"},
756
+ "timescale": {"名称": "分化时间", "主注解": ["timescale"], "支撑": ["bootstrap"],
757
+ "说明": "主张=分化时间。时间轴为主注解(物种树用 posterior 非 bootstrap)。"},
758
+ "domain": {"名称": "结构域组织", "主注解": ["domains"], "支撑": ["subfamily"],
759
+ "说明": "主张=蛋白结构域组织与进化的关系。结构域示意为主注解。"},
760
+ "expression": {"名称": "表达模式", "主注解": ["heatmap"], "支撑": ["subfamily"],
761
+ "说明": "主张=基因表达模式随进化的变化。表达热图为主注解(行序=树叶序)。"},
762
+ "selection": {"名称": "选择压力", "主注解": ["selection"], "支撑": ["subfamily"],
763
+ "说明": "主张=选择压力/正选择。ω 渐变为主注解,正选择枝高亮。"},
764
+ "synteny": {"名称": "共线性/串联", "主注解": ["connections"], "支撑": ["subfamily"],
765
+ "说明": "主张=共线性/串联复制关系。共线连线为主注解。"},
766
+ }
767
+
768
+
769
+ @main.command()
770
+ @click.argument("claim", type=click.Choice(sorted(_CLAIM_PRESETS)))
771
+ @click.option("--tree", type=click.Path(exists=True, path_type=Path), default=None, help="树文件(算叶数给布局建议)")
772
+ @click.option("--groups", "n_groups", type=int, default=None, help="分组数(给色板建议)")
773
+ def plan(claim: str, tree: Path | None, n_groups: int | None) -> None:
774
+ """主张驱动:你要论证什么 → 推荐注解组合(Rougier R2 主张决定注解)。
775
+
776
+ 逆向工作流:先说主张,再选注解。itol plan <主张> 给出主注解+支撑注解的组合建议
777
+ +生成命令+布局/色板的美学护栏。itol plan --list 看全部主张。
778
+ """
779
+ if claim not in _CLAIM_PRESETS:
780
+ raise click.UsageError(f"未知主张 {claim}。可选:{'、'.join(sorted(_CLAIM_PRESETS))}")
781
+ p = _CLAIM_PRESETS[claim]
782
+ click.echo(f"主张:{p['名称']}\n {p['说明']}\n")
783
+ click.echo(f"主注解(视觉最强):{'、'.join(p['主注解'])}")
784
+ click.echo(f"支撑注解:{'、'.join(p['支撑'])}")
785
+ # 密度护栏:推荐的注解组合映射到视觉通道,超通道预算就告警(Wilke:单图 ≤5 scale)
786
+ _CHANNEL_OF = {"subfamily": "color", "bootstrap": "size", "timescale": "position",
787
+ "domains": "shape", "heatmap": "color", "selection": "color",
788
+ "connections": "linetype", "pav": "color", "motif": "shape"}
789
+ layers = {_CHANNEL_OF.get(a, "color"): a for a in (list(p["主注解"]) + list(p["支撑"]))}
790
+ for dw in _aesthetics.channel_budget_check(layers):
791
+ click.echo(f" {dw}", err=True)
792
+ if len(layers) > 3:
793
+ click.echo(f" 💡 密度提示:组合占 {len(layers)} 个视觉通道,注意主注解要视觉最强", err=True)
794
+ # 布局/色板美学护栏(给了树就按叶数推荐布局)
795
+ if tree is not None:
796
+ try:
797
+ _, root = _tree.load_tree(tree)
798
+ n_leaves = len(root.leaves())
799
+ click.echo(f"\n树:{n_leaves} 叶")
800
+ for w in _aesthetics.readability_warnings(n_leaves, "rect"):
801
+ click.echo(f" {w}", err=True)
802
+ pt = _aesthetics.recommend_font_pt(n_leaves)
803
+ click.echo(f" 建议叶标签字号:{pt}pt;布局:{'circular' if n_leaves > 60 else 'rect'}")
804
+ except ItolError as e:
805
+ click.echo(str(e), err=True)
806
+ if n_groups:
807
+ pname, _, pwarn = _aesthetics.recommend_palette(n_groups)
808
+ click.echo(f" 建议色板:{pname}")
809
+ for w in pwarn:
810
+ click.echo(f" {w}", err=True)
811
+ click.echo(f"\n生成命令:itol gen {p['主注解'][0]} <输入文件>" + (f" --tree {tree}" if tree else ""))
812
+
813
+
814
+ @main.command(name="upload-tree")
815
+ @click.argument("tree_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
816
+ @click.option("--project", "project_id", required=True, help="目标项目 ID(itol workspaces 查)")
817
+ @click.option("--name", default=None, help="树名(缺省用文件名)")
818
+ def upload_tree(tree_file: Path, project_id: str, name: str | None) -> None:
819
+ """把树文本上传到账号项目(免费账号可用;注解挂树需订阅 batch_uploader 或浏览器自动化)。"""
820
+ acc = AccountClient()
821
+ name = name or tree_file.stem
822
+ try:
823
+ tid = acc.upload_tree_text(project_id, name, tree_file.read_text(encoding="utf-8"))
824
+ click.echo(f"✅ 树已上传:id={tid}\n 查看:https://itol.embl.de/tree/{tid}")
825
+ except ItolError as e:
826
+ click.echo(f"❌ {e}", err=True)
827
+ raise SystemExit(1) from None
828
+
829
+
830
+ @main.command()
831
+ @click.argument("tree_id")
832
+ @click.option("--anno", multiple=True, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path),
833
+ help="注解文件(可多个;DATASET_* 缺 DATASET_LABEL 会自动补)")
834
+ @click.option("--out", required=True, type=click.Path(path_type=Path), help="输出 SVG 路径")
835
+ @click.option("--png", "png", default=None, type=click.Path(path_type=Path), help="可选 PNG 路径(需 rsvg-convert)")
836
+ @click.option("--wait", default=60, show_default=True, help="iTOL 数据模型等待超时(秒)")
837
+ @click.option("--mode", default=None, type=click.Choice(["rect", "circular", "unrooted"]),
838
+ help="显示模式:矩形/环形/无根(缺省用 iTOL 默认:<100 叶矩形,否则环形)")
839
+ @click.option("--dpi", default=None, type=int,
840
+ help="高分辨率位图 dpi(≤1000;走服务端 export.cgi 队列,需 itol login,比本地 rsvg 更清)")
841
+ @click.option("--dry-run", is_flag=True, help="全校验不起浏览器(校验树 ID 可达 + lint 注解)")
842
+ def render(tree_id: str, anno: tuple, out: Path, png: Path | None, wait: int, mode: str | None,
843
+ dpi: int | None, dry_run: bool) -> None:
844
+ """浏览器渲染路:注入注解到 iTOL 树页出带注解图(免费账号可用;Xvfb+playwright,自动套虚拟显示)。
845
+
846
+ 需本机有 Chrome/Chromium;无显示环境自动 xvfb-run 包裹。出带 colorstrip 等真数据集的图唯一程序化路。
847
+ --dpi 触发服务端高分辨率位图(export.cgi 队列,免费账号可用,上限 1000dpi)。
848
+ --dry-run:校验树 ID 可达 + lint 注解,不起浏览器(快速预检)。
849
+ """
850
+ if dry_run:
851
+ ok = _client().tree_exists(tree_id)
852
+ click.echo(f"{'✅' if ok else '❌'} 树 {tree_id} {'可达' if ok else '不可达/已过期'}")
853
+ reports = lint_paths(list(anno))
854
+ bad = [r for r in reports if not r.ok]
855
+ for r in reports:
856
+ click.echo(r.summary())
857
+ if bad:
858
+ ok = False
859
+ click.echo(f"{'✅' if ok else '❌'} render dry-run:{'可渲染' if ok else '有问题'}")
860
+ sys.exit(0 if ok else 1)
861
+ _render.ensure_display(sys.argv[1:]) # 无 DISPLAY 自动 xvfb-run 重跑本命令
862
+ try:
863
+ if dpi is not None and png is not None:
864
+ # 高分辨率位图:render 只出 SVG,再走 export.cgi 服务端队列转 PNG
865
+ result = _render.render(tree_id, list(anno), out, None, wait, mode=mode)
866
+ png.write_bytes(
867
+ AccountClient().export_hires_png(out.read_text(encoding="utf-8"), tree_id, dpi))
868
+ result["png"] = str(png)
869
+ else:
870
+ result = _render.render(tree_id, list(anno), out, png, wait, mode=mode)
871
+ except ItolError as e:
872
+ click.echo(f"❌ {e}", err=True)
873
+ raise SystemExit(1) from None
874
+ for f, ok in result["injected"].items():
875
+ click.echo(f"{'✅' if ok else '⚠️ '} 注入 {f}")
876
+ click.echo(f"✅ SVG 已出:{result['svg']}({result['svg_chars']} 字符)")
877
+ if "png" in result:
878
+ click.echo(f"✅ PNG 已出:{result['png']}")
879
+ if "png_warn" in result:
880
+ click.echo(f"⚠️ {result['png_warn']}", err=True)
881
+ if not result["ok"]:
882
+ click.echo("⚠️ 部分注解未确认注入(可能静默拒绝——查 DATASET_LABEL 是否缺失)", err=True)
883
+ raise SystemExit(3)
884
+
885
+
886
+ if __name__ == "__main__":
887
+ main()