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/tree.py ADDED
@@ -0,0 +1,507 @@
1
+ """纯 Python Newick 树引擎(零第三方依赖)。
2
+
3
+ 为 itol-cli 提供树结构校验/统计/预处理/单系检验能力——对标 gotree/nwkit/PhyKIT 的树侧功能。
4
+ 设计哲学:lint 拦静默坑(烂树别进 gen→render),tree 子命令做上 iTOL 前的高频预处理。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from .errors import ItolError, invalid_input
13
+
14
+ # 不可见/对抗字符(零宽空格、BOM、bidi 控制符等)——AI 生成文件常混入
15
+ _INVISIBLE = {
16
+ "\u200b": "零宽空格 U+200B",
17
+ "\u200c": "零宽非连接符 U+200C",
18
+ "\u200d": "零宽连接符 U+200D",
19
+ "\ufeff": "BOM U+FEFF",
20
+ "\u202a": "bidi LRE U+202A",
21
+ "\u202b": "bidi RLE U+202B",
22
+ "\u202c": "bidi PDF U+202C",
23
+ "\u202e": "bidi RLO U+202E",
24
+ "\u00a0": "不换行空格 U+00A0",
25
+ }
26
+
27
+
28
+ @dataclass
29
+ class Node:
30
+ """树节点。name 叶标签或内部节点名,bl 支长,children 子节点。"""
31
+ name: str = ""
32
+ bl: float | None = None
33
+ children: list[Node] = field(default_factory=list)
34
+ parent: Node | None = field(default=None, repr=False, compare=False)
35
+
36
+ @property
37
+ def is_leaf(self) -> bool:
38
+ return not self.children
39
+
40
+ def leaves(self) -> list[Node]:
41
+ """所有后代叶节点。"""
42
+ if self.is_leaf:
43
+ return [self]
44
+ out: list[Node] = []
45
+ for c in self.children:
46
+ out.extend(c.leaves())
47
+ return out
48
+
49
+ def iter(self) -> list[Node]:
50
+ """全树先序遍历(含自身)。"""
51
+ out = [self]
52
+ for c in self.children:
53
+ out.extend(c.iter())
54
+ return out
55
+
56
+ def to_newick(self) -> str:
57
+ if self.is_leaf:
58
+ return _quote(self.name) + (f":{self.bl:g}" if self.bl is not None else "")
59
+ inner = "(" + ",".join(c.to_newick() for c in self.children) + ")"
60
+ return inner + self.name + (f":{self.bl:g}" if self.bl is not None else "")
61
+
62
+
63
+ def _quote(name: str) -> str:
64
+ return f"'{name}'" if (name and re.search(r"[\s,():\[\];']", name)) else name
65
+
66
+
67
+ def parse_newick(text: str) -> Node:
68
+ """解析 Newick 文本为 Node 树。支持单/多树(取第一棵)、NHX 注释忽略、引号标签。"""
69
+ s = text.strip()
70
+ if not s:
71
+ raise invalid_input("树文件为空")
72
+ # 多树/多行:取第一个完整树(到第一个顶层分号)
73
+ first_semi = _find_top_semicolon(s)
74
+ if first_semi != -1:
75
+ s = s[: first_semi + 1]
76
+ s = re.sub(r"\[&&NHX:[^\]]*\]", "", s) # 去 NHX 注释
77
+ pos = 0
78
+
79
+ def parse_sub() -> Node:
80
+ nonlocal pos
81
+ node = Node()
82
+ if pos < len(s) and s[pos] == "(":
83
+ pos += 1 # (
84
+ while True:
85
+ child = parse_sub()
86
+ child.parent = node
87
+ node.children.append(child)
88
+ if pos < len(s) and s[pos] == ",":
89
+ pos += 1
90
+ continue
91
+ if pos < len(s) and s[pos] == ")":
92
+ pos += 1
93
+ break
94
+ break
95
+ # 标签
96
+ name = _read_label()
97
+ node.name = name
98
+ # 支长
99
+ if pos < len(s) and s[pos] == ":":
100
+ pos += 1
101
+ blstr = _read_brlen()
102
+ if blstr != "":
103
+ try:
104
+ node.bl = float(blstr)
105
+ except ValueError:
106
+ raise invalid_input(f"非法支长 {blstr!r}(节点 {name or '?'})") from None
107
+ return node
108
+
109
+ def _read_label() -> str:
110
+ nonlocal pos
111
+ if pos < len(s) and s[pos] in "'\"":
112
+ q = s[pos]
113
+ pos += 1
114
+ start = pos
115
+ while pos < len(s) and s[pos] != q:
116
+ pos += 1
117
+ val = s[start:pos]
118
+ pos += 1 # 跳过引号
119
+ return val
120
+ start = pos
121
+ while pos < len(s) and s[pos] not in ",():;":
122
+ pos += 1
123
+ return s[start:pos].strip()
124
+
125
+ def _read_brlen() -> str:
126
+ nonlocal pos
127
+ start = pos
128
+ while pos < len(s) and s[pos] in "0123456789.eE+-":
129
+ pos += 1
130
+ return s[start:pos]
131
+
132
+ root = parse_sub()
133
+ if pos < len(s) and s[pos] == ";":
134
+ pos += 1
135
+ if s[pos:].strip():
136
+ raise invalid_input(f"Newick 解析残留:{s[pos:pos+30]!r}(括号可能不平衡)")
137
+ return root
138
+
139
+
140
+ def _find_top_semicolon(s: str) -> int:
141
+ """找顶层(非引号内)第一个分号。"""
142
+ depth = 0
143
+ in_q: str | None = None
144
+ for i, ch in enumerate(s):
145
+ if in_q:
146
+ if ch == in_q:
147
+ in_q = None
148
+ continue
149
+ if ch in "'\"":
150
+ in_q = ch
151
+ elif ch == "(":
152
+ depth += 1
153
+ elif ch == ")":
154
+ depth -= 1
155
+ elif ch == ";" and depth == 0:
156
+ return i
157
+ return -1
158
+
159
+
160
+ def validate_tree_text(text: str) -> list[str]:
161
+ """校验树文本,返回问题列表(空=通过)。对标 gotree stats/PyiTOL validate/PhyKIT。"""
162
+ issues: list[str] = []
163
+ # 不可见/对抗字符
164
+ for ch, desc in _INVISIBLE.items():
165
+ if ch in text:
166
+ issues.append(f"含不可见字符 {desc}(AI 生成文件常见,会导致 iTOL 静默解析失败)")
167
+ if any(ord(c) < 32 and c not in "\t\n\r" for c in text):
168
+ issues.append("含控制字符")
169
+ # 括号平衡
170
+ if text.count("(") != text.count(")"):
171
+ issues.append(f"括号不平衡(( {text.count('(')} vs ) {text.count(')')})")
172
+ return issues # 括号不平衡就没法解析,提前返回
173
+ try:
174
+ root = parse_newick(text)
175
+ except ItolError as e:
176
+ issues.append(str(e))
177
+ return issues
178
+ allnodes = root.iter()
179
+ leaves = [n for n in allnodes if n.is_leaf]
180
+ internal = [n for n in allnodes if not n.is_leaf]
181
+ # 重复叶名
182
+ names = [n.name for n in leaves if n.name]
183
+ seen: set[str] = set()
184
+ dups: set[str] = set()
185
+ for n in names:
186
+ if n in seen:
187
+ dups.add(n)
188
+ seen.add(n)
189
+ if dups:
190
+ issues.append(f"重复叶标签:{', '.join(sorted(dups)[:5])}{' 等' if len(dups) > 5 else ''}")
191
+ # 空叶名
192
+ if any(not n.name for n in leaves):
193
+ issues.append("存在空叶标签")
194
+ # 负支长
195
+ if any(n.bl is not None and n.bl < 0 for n in allnodes):
196
+ issues.append("存在负支长(iTOL 会拒绝)")
197
+ # 多根
198
+ if len(root.children) == 1:
199
+ issues.append("根节点只有一个子(疑似多根/未解决)")
200
+ # 多叉
201
+ poly = sum(1 for n in internal if len(n.children) > 2)
202
+ if poly:
203
+ issues.append(f"含 {poly} 个多叉节点(iTOL 对多叉渲染/着色有坑,建议 resolve)")
204
+ return issues
205
+
206
+
207
+ def tree_stats_text(text: str) -> dict:
208
+ """树统计摘要(gotree stats / PhyKIT 思路)。返回 dict。"""
209
+ root = parse_newick(text)
210
+ allnodes = root.iter()
211
+ leaves = [n for n in allnodes if n.is_leaf]
212
+ internal = [n for n in allnodes if not n.is_leaf]
213
+ bls = [n.bl for n in allnodes if n.bl is not None]
214
+ supports = []
215
+ for n in internal:
216
+ if n.name:
217
+ try:
218
+ supports.append(float(n.name))
219
+ except ValueError:
220
+ pass
221
+ poly = sum(1 for n in internal if len(n.children) > 2)
222
+
223
+ def height(n: Node) -> float:
224
+ if n.is_leaf:
225
+ return 0.0
226
+ return max((c.bl or 0.0) + height(c) for c in n.children)
227
+
228
+ return {
229
+ "tips": len(leaves),
230
+ "internal": len(internal),
231
+ "total_nodes": len(allnodes),
232
+ "polytomy": poly,
233
+ "has_brl": any(bls),
234
+ "sum_brlen": sum(bls) if bls else 0.0,
235
+ "mean_brlen": (sum(bls) / len(bls)) if bls else 0.0,
236
+ "tree_height": height(root),
237
+ "n_support": len(supports),
238
+ "min_support": min(supports) if supports else None,
239
+ "rooted": len(root.children) >= 2,
240
+ }
241
+
242
+
243
+ def midpoint_reroot(root: Node) -> Node:
244
+ """中点定根(midpoint rooting)。
245
+
246
+ ⚠️ 当前为保守实现:完整重挂涉及在最长路径中点劈边插新根,实现复杂。
247
+ 本版本暂返回原树并提示——精确 midpoint reroot 建议用 `ete3`(itol tree reroot --midpoint 会在装了 ete3 时调用)。
248
+ """
249
+ return root
250
+
251
+
252
+ def dist_from_root(n: Node, root: Node) -> float:
253
+ d = 0.0
254
+ while n is not None and n is not root:
255
+ d += n.bl or 0.0
256
+ n = n.parent # type: ignore[assignment]
257
+ return d
258
+
259
+
260
+ def prune(root: Node, keep: set[str] | None = None, remove: set[str] | None = None) -> Node:
261
+ """剪枝:keep=保留这些叶(其余删)或 remove=删这些叶。"""
262
+ def filt(n: Node) -> Node | None:
263
+ if n.is_leaf:
264
+ if keep is not None:
265
+ return n if n.name in keep else None
266
+ if remove is not None:
267
+ return None if n.name in remove else n
268
+ return n
269
+ n.children = [c for c in (filt(c) for c in n.children) if c is not None]
270
+ for c in n.children:
271
+ c.parent = n
272
+ if not n.children:
273
+ return None
274
+ if len(n.children) == 1: # 去二度节点
275
+ c = n.children[0]
276
+ c.bl = (c.bl or 0.0) + (n.bl or 0.0)
277
+ c.parent = n.parent
278
+ return c
279
+ return n
280
+ out = filt(root)
281
+ if out is None:
282
+ raise invalid_input("prune 后树为空(keep/remove 名单与树无交集)")
283
+ out.parent = None
284
+ return out
285
+
286
+
287
+ def rename(root: Node, mapping: dict[str, str]) -> int:
288
+ """按映射重命名叶标签。返回改了几个。"""
289
+ n = 0
290
+ for leaf in root.leaves():
291
+ if leaf.name in mapping:
292
+ leaf.name = mapping[leaf.name]
293
+ n += 1
294
+ return n
295
+
296
+
297
+ def monophyly_groups(root: Node, groups: dict[str, list[str]]) -> list[tuple[str, bool, str]]:
298
+ """单系检验:每个分组是否单系。返回 [(组名, 是否单系, 说明)]。"""
299
+ results = []
300
+ leafmap = {n.name: n for n in root.leaves()}
301
+ for gname, members in groups.items():
302
+ present = [leafmap[m] for m in members if m in leafmap]
303
+ if len(present) < 2:
304
+ results.append((gname, True, f"组内<2 个成员在树中({len(present)}/{len(members)})"))
305
+ continue
306
+ # 找这些叶的 LCA,看 LCA 的后代叶是否全在该组
307
+ lca = _lca(present)
308
+ if lca is None:
309
+ results.append((gname, False, "无法定位共同祖先"))
310
+ continue
311
+ lca_leaves = {n.name for n in lca.leaves()}
312
+ extra = lca_leaves - set(members)
313
+ if extra:
314
+ results.append((gname, False, f"非单系:共同祖先下混入 {len(extra)} 个外组叶(如 {sorted(extra)[:3]})"))
315
+ else:
316
+ results.append((gname, True, f"单系 ✓({len(present)} 叶)"))
317
+ return results
318
+
319
+
320
+ def _lca(nodes: list[Node]) -> Node | None:
321
+ """多个节点的最近共同祖先。"""
322
+ if not nodes:
323
+ return None
324
+ paths: list[list[Node]] = []
325
+ for n0 in nodes:
326
+ p: list[Node] = []
327
+ n2: Node | None = n0
328
+ while n2 is not None:
329
+ p.append(n2)
330
+ n2 = n2.parent
331
+ paths.append(p)
332
+ # 第一条路径上找在所有路径里的最深节点
333
+ for cand in paths[0]:
334
+ if all(cand in p for p in paths[1:]):
335
+ return cand
336
+ return None
337
+
338
+
339
+ def draw_ascii(root: Node, width: int = 60) -> str:
340
+ """终端 ASCII 树速览(gotree draw text 思路)。返回字符串。"""
341
+ lines: list[str] = []
342
+
343
+ def rec(n: Node, prefix: str, is_last: bool) -> None:
344
+ connector = "└── " if is_last else "├── "
345
+ label = n.name or ("·" if n.is_leaf else "")
346
+ bl = f" :{n.bl:g}" if n.bl is not None else ""
347
+ lines.append(prefix_holder[0] + connector + label + bl)
348
+ extension = " " if is_last else "│ "
349
+ old = prefix_holder[0]
350
+ prefix_holder[0] = old + extension
351
+ for i, c in enumerate(n.children):
352
+ rec(c, prefix_holder[0], i == len(n.children) - 1)
353
+ prefix_holder[0] = old
354
+
355
+ prefix_holder = [""]
356
+ label = root.name or "root"
357
+ lines.append(label + (f" :{root.bl:g}" if root.bl is not None else ""))
358
+ for i, c in enumerate(root.children):
359
+ rec(c, "", i == len(root.children) - 1)
360
+ out = "\n".join(lines)
361
+ if len(out.splitlines()) > 100:
362
+ out = "\n".join(out.splitlines()[:100]) + f"\n… ({len(lines)-100} 行省略)"
363
+ _ = width
364
+ return out
365
+
366
+
367
+ def load_tree(path: Path) -> tuple[str, Node]:
368
+ """读树文件并解析。返回 (原文, 根节点)。"""
369
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
370
+ return text, parse_newick(text)
371
+
372
+
373
+ def reroot_outgroup(root: Node, outgroup_names: set[str]) -> Node:
374
+ """在 outgroup(这些叶的 MRCA 分支)上方重定根。返回新根。
375
+
376
+ 算法:找 target(outgroup 叶的 MRCA),翻转 target→旧根路径上的父子关系,
377
+ 新根挂在 target 与其原父之间的边上(支长中点)。对标 gotree reroot --outgroup。
378
+ """
379
+ targets = [n for n in root.leaves() if n.name in outgroup_names]
380
+ if not targets:
381
+ raise invalid_input(f"outgroup {sorted(outgroup_names)} 都不在树中")
382
+ target = _lca(targets) or targets[0]
383
+ if target is root or target.parent is None:
384
+ return root
385
+ # 路径 target → ... → root
386
+ path: list[Node] = []
387
+ n: Node | None = target
388
+ while n is not None:
389
+ path.append(n)
390
+ n = n.parent
391
+ # path[0]=target ... path[-1]=root;逐对翻转
392
+ # 新根 = 挂 target 和「翻转后的其余部分」的二叉根
393
+ new_root = Node(name="")
394
+ half = (target.bl or 0.0) / 2.0
395
+ # 翻转:从 target.parent 往上,每个节点的父子关系倒过来
396
+ # path[1] 是 target 的原父;让 path[i+1] 变成 path[i] 的父
397
+ # 先摘出 target
398
+ old_parent = path[1]
399
+ if target in old_parent.children:
400
+ old_parent.children.remove(target)
401
+ target.parent = new_root
402
+ target.bl = half
403
+ new_root.children.append(target)
404
+ # 翻转路径 path[1..]:path[i] 的父变成 path[i+1] 的... 这里用递归翻转
405
+ _flip_path(path[1:], new_root, half)
406
+ return new_root
407
+
408
+
409
+ def _flip_path(chain: list[Node], new_root: Node, first_bl: float) -> None:
410
+ """把 chain=[旧父, ..., 旧根] 翻转后挂到 new_root 下。
411
+
412
+ chain[0](target 的旧父)翻转后其父是 chain[1],...,旧根成为最末端。
413
+ 最顶端节点(翻转后无父)挂到 new_root。
414
+ """
415
+ # 反转:chain[-1](旧根)成为链底,其父是 chain[-2]
416
+ # 逐个重挂:对 i 从 len-1 到 1,chain[i-1] 的父 = chain[i]
417
+ for i in range(len(chain) - 1, 0, -1):
418
+ parent = chain[i] # 更靠近旧根
419
+ child = chain[i - 1] # 更靠近 target
420
+ # child 原来的父是 parent;翻转后 child 变成 parent 的父
421
+ if child in parent.children:
422
+ parent.children.remove(child)
423
+ # child 原来在 parent 下的支长 = child.bl;翻转后 parent 挂在 child 下,支长取 parent.bl
424
+ parent.parent = child
425
+ child.children.append(parent)
426
+ parent.bl = child.bl
427
+ # 链底(翻转后 chain[0],即 target 的旧父)挂到 new_root
428
+ top = chain[0]
429
+ top.parent = new_root
430
+ top.bl = first_bl
431
+ new_root.children.append(top)
432
+ # 旧根若变成单叉,压扁
433
+ old_root = chain[-1]
434
+ if len(old_root.children) == 1:
435
+ only = old_root.children[0]
436
+ only.bl = (only.bl or 0.0) + (old_root.bl or 0.0)
437
+ # 把 only 接到 old_root.parent
438
+ gp = old_root.parent
439
+ if gp is not None and old_root in gp.children:
440
+ gp.children.remove(old_root)
441
+ only.parent = gp
442
+ gp.children.append(only)
443
+
444
+
445
+ def tree_to_svg(root: Node, width: int = 900, leaf_h: int = 22, use_brl: bool = True) -> str:
446
+ """纯 Python 本地树→SVG 渲染(零依赖、离线)。对标 gotree draw svg / nw_display。
447
+
448
+ 矩形系统树(phylogram:支长真实;use_brl=False 时 cladogram:等深)。
449
+ 供 render --backend local 离线降级/快速预览。
450
+ """
451
+ leaves = root.leaves()
452
+ n = len(leaves)
453
+ height = max(80, n * leaf_h + 60)
454
+ # y 坐标:叶按顺序,内部节点=子节点均值
455
+ ymap: dict[int, float] = {}
456
+
457
+ def assign_y(node: Node) -> float:
458
+ if node.is_leaf:
459
+ ymap[id(node)] = 40 + leaves.index(node) * leaf_h
460
+ else:
461
+ assign_y_children = [assign_y(c) for c in node.children]
462
+ ymap[id(node)] = sum(assign_y_children) / len(assign_y_children)
463
+ return ymap[id(node)]
464
+ assign_y(root)
465
+ # x 坐标:累计支长(或等深)
466
+ xmax = 0.0
467
+
468
+ def assign_x(node: Node, acc: float) -> None:
469
+ nonlocal xmax
470
+ xmax = max(xmax, acc)
471
+ for c in node.children:
472
+ step = (c.bl or 0.0) if use_brl else 1.0
473
+ assign_x(c, acc + step)
474
+ assign_x(root, 0.0)
475
+ if xmax <= 0:
476
+ xmax = 1.0
477
+ draw_w = width - 180 # 留叶标签空间
478
+
479
+ def sx(acc: float) -> float:
480
+ return 20 + (acc / xmax) * draw_w
481
+
482
+ svg_open = f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" font-family="monospace" font-size="13">'
483
+ parts = [svg_open, f'<rect width="{width}" height="{height}" fill="white"/>']
484
+
485
+ def draw(node: Node, acc: float) -> None:
486
+ x0 = sx(acc)
487
+ y0 = ymap[id(node)]
488
+ for c in node.children:
489
+ step = (c.bl or 0.0) if use_brl else 1.0
490
+ x1 = sx(acc + step)
491
+ y1 = ymap[id(c)]
492
+ # 横线(父→子的支长)
493
+ parts.append(f'<line x1="{x0:.1f}" y1="{y1:.1f}" x2="{x1:.1f}" y2="{y1:.1f}" stroke="#333" stroke-width="1.2"/>')
494
+ # 竖线(父节点连接各子的 y 范围)
495
+ parts.append(f'<line x1="{x0:.1f}" y1="{y0:.1f}" x2="{x0:.1f}" y2="{y1:.1f}" stroke="#333" stroke-width="1.2"/>')
496
+ draw(c, acc + step)
497
+ if node.is_leaf and node.name:
498
+ parts.append(f'<text x="{x0+5:.1f}" y="{y0+4:.1f}" fill="#111">{_xml_escape(node.name)}</text>')
499
+ elif node.name and not node.is_leaf:
500
+ parts.append(f'<text x="{x0+3:.1f}" y="{y0-4:.1f}" fill="#888" font-size="11">{_xml_escape(node.name)}</text>')
501
+ draw(root, 0.0)
502
+ parts.append('</svg>')
503
+ return "\n".join(parts)
504
+
505
+
506
+ def _xml_escape(s: str) -> str:
507
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: itol-cli
3
+ Version: 0.2.0
4
+ Summary: iTOL command-line toolkit: annotation template generation, lint, upload/export pipeline
5
+ Author: MOSS
6
+ License-Expression: MIT
7
+ Keywords: itol,phylogenetic,tree,annotation,bioinformatics
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: click>=8.0
11
+ Requires-Dist: requests>=2.28
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7; extra == "dev"
14
+ Requires-Dist: ruff; extra == "dev"
15
+ Requires-Dist: mypy; extra == "dev"
16
+ Provides-Extra: render
17
+ Requires-Dist: playwright>=1.40; extra == "render"
18
+
19
+ # itol-cli
20
+
21
+ iTOL(itol.embl.de)命令行工具:**注解模板生成 + 本地 lint 校验 + 上传/导出流水线**。
22
+
23
+ 设计哲学:**设计即数据**——注解文件是一等公民的纯文本模板(人/AI 可直接手写),CLI 负责生成、校验、上传、导出闭环。
24
+
25
+ ```bash
26
+ uv tool install itol-cli # 首推:免 Python 环境(MultiQC 同款推荐)
27
+ pip install itol-cli # 或走 pip(发布后)
28
+ # 或源码:pip install -e .
29
+ ```
30
+
31
+ ## 10 秒上手
32
+
33
+ ```bash
34
+ itol doctor # 环境自检(API key/端点存活)
35
+ itol templates # 场景模板库
36
+ itol gen subfamily groups.tsv # 分组 tsv → TREE_COLORS + COLORSTRIP
37
+ itol gen subfamily g.tsv --tree tree.nwk # 顺带自动验亚家族单系
38
+ itol lint itol_colorstrip.txt # 本地校验注解文件
39
+ itol lint anno.txt --tree tree.nwk # 交叉校验:注解 ID 是否真在树里
40
+ itol export 123456789 --format svg -o tree.svg # 导出(免认证)
41
+ itol upload tree.tree ann1.txt --project MyProj # 上传(需订阅+key)
42
+ itol upload tree.tree ann1.txt --project P --dry-run # 干跑:全校验不真传
43
+ itol render 123 --anno a.txt --out f.svg --dry-run # 干跑:校验树可达+注解,不起浏览器
44
+ ```
45
+
46
+ ## 树文件工具(对标 gotree/nwkit/PhyKIT,上 iTOL 前的预处理)
47
+
48
+ ```bash
49
+ itol tree validate tree.nwk # 校验树文件(括号/重名/负支长/多叉/不可见字符)
50
+ itol tree stats tree.nwk # 树统计(叶数/支长/支持度/多叉/树高)
51
+ itol tree peek tree.nwk # 终端 ASCII 速览树结构(SSH/CI 无浏览器)
52
+ itol tree reroot t.nwk --outgroup A -o rr.nwk # 定根(外群/中点)
53
+ itol tree prune t.nwk --keep A,B,C -o p.nwk # 剪枝
54
+ itol tree rename t.nwk --map map.tsv -o rn.nwk # 按映射表重命名叶标签
55
+ itol tree render tree.nwk -o t.svg # 本地渲染→SVG(零网络/浏览器,离线降级)
56
+ ```
57
+
58
+ ## 工程特性
59
+
60
+ ```bash
61
+ itol completion bash # 生成 shell 补全脚本(eval "$(itol completion bash)")
62
+ itol commands --format json # 机器可读命令目录(llms.txt 素材)
63
+ itol learn old_anno.txt -o style.json # 逆向复用:学已有注解的样式
64
+ itol learn old.txt --apply new.tsv -o new.txt # 套样式到新数据
65
+ ```
66
+
67
+ 错误码公开文档:docs/ERROR_CODES.md(IT001-006 触发条件→修法)。
68
+
69
+ ## 关键事实(2026-11-02 官方文档+curl 实证)
70
+
71
+ - **上传/删除**需 iTOL 标准订阅 + APIkey(`userInfo.cgi` 生成;订阅见 pricing.cgi);`export ITOL_API_KEY=...` 或写 `~/.itol-cli/config`
72
+ - **导出免认证**:公开树按数字 ID 即可导出;格式 svg/eps/pdf/png/newick/nexus/phyloxml
73
+ - **导出无 dpi/宽高参数**——高分辨率位图是缺口,默认用 SVG/PDF/EPS 矢量
74
+ - 官方无速率限制,本工具默认串行+1s 间隔+指数退避
75
+ - 分享 URL(/tree/\<id\>)无鉴权,知 ID 即可访问——注意未发表数据
76
+
77
+ ## 场景模板库
78
+
79
+ | 场景 | 输入 | 产出 |
80
+ |:------|:------|:------|
81
+ | `subfamily` 亚家族着色主树 | 分组 tsv(node_id TAB group) | TREE_COLORS + COLORSTRIP(Okabe-Ito/Tol 18 色板) |
82
+ | `bootstrap` 支持率展示 | IQ-TREE .contree | 导出参数预设(≥95 实心点规则) |
83
+ | `heatmap` 表达热图联树 | 矩阵 tsv(首列=树叶名) | DATASET_HEATMAP(内建行序=树序校验) |
84
+ | `domains` 域结构联树 | 域表 tsv(gene/domain/start/end/len) | DATASET_DOMAINS(官方原生类型) |
85
+ | `motif` 基序联树 | motif 表 tsv(同构) | DATASET_DOMAINS(motif 作色块) |
86
+ | `selection` 选择压力标注树 | ω 表 tsv(node TAB omega) | TREE_COLORS(枝按 ω 蓝→白→红渐变) |
87
+ | `connections` 共线连线树/tanglegram | 配对表 tsv(node1 TAB node2 [color] [label]) | DATASET_CONNECTION |
88
+ | `timescale` 时间树地质年代轴 | 区间表 tsv(name TAB start TAB end [color]) | DATASET_TIMESCALE |
89
+ | `pav` 泛基因组 PAV 联树 | 0/1 矩阵 tsv(首列=树叶名) | DATASET_BINARY |
90
+
91
+ ## 错误契约
92
+
93
+ IT001 INVALID_INPUT / IT002 UPLOAD_FAILED / IT003 EXPORT_FAILED / IT004 AUTH_REQUIRED / IT005 RATE_LIMITED / IT006 INTERNAL——每个错误带 retryable 与诊断建议。
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,16 @@
1
+ itol_cli/__init__.py,sha256=UWtZtzOS6ylW-jkhnj1sBmhetpPM0Brgey5nNCHG2t8,273
2
+ itol_cli/account.py,sha256=WY-H5Q1RnqO0KIbzgV8KLLgCUv6arVkD1HIGp4FjV9g,7513
3
+ itol_cli/aesthetics.py,sha256=lRJgHHEBHaCkGAoEsIaDTmxVOkSKgx55URabItnp6WA,11821
4
+ itol_cli/cli.py,sha256=k4NBvmGmONX2_8JfSBxHUB03TXJ-SzG7WiattBXgEK8,42842
5
+ itol_cli/client.py,sha256=AJ3c4m-X7Zzp94WgecNxoFx3h5gjo7NTDCpJFOQVdh4,9029
6
+ itol_cli/errors.py,sha256=GDb78MWoQhvCAUfitn7eJksvtXxQfOQKYr8Iu_H428o,2330
7
+ itol_cli/learn.py,sha256=0wt_SbyeITlYgyG1R2a5KRYR1okVp0MOUxWR_FMXVNI,7684
8
+ itol_cli/lint.py,sha256=268eslBhTuq7n2N_-kKa1nwGhmi4NyibWDofUw1CvnE,11678
9
+ itol_cli/render.py,sha256=WFigrM5J0UMAxdiJq6fIErAM9jYxnfqnplfjAUDCNPA,7090
10
+ itol_cli/templates.py,sha256=_9tE6f42xRwcz4jxehEG5rUr8f9BZri3_bF4z0OCEJA,17237
11
+ itol_cli/tree.py,sha256=UO_BGEu9zX3PFkH9w9pdMuroLXwuw3vUIGtz082S7_4,18533
12
+ itol_cli-0.2.0.dist-info/METADATA,sha256=CqnjlcyPKVFQcvrgQWl0SU922LwnafDNjX9iIkdj3FU,4841
13
+ itol_cli-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ itol_cli-0.2.0.dist-info/entry_points.txt,sha256=pCkrQ0jlJPQD48PQxGdTBbDh_DCUvjM1PifixidV_Eg,43
15
+ itol_cli-0.2.0.dist-info/top_level.txt,sha256=e_eQ6s4jQNWyPbNJ8aoZD58_i6bDMJ3C9sPOQ6aT1WQ,9
16
+ itol_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ itol = itol_cli.cli:main
@@ -0,0 +1 @@
1
+ itol_cli