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/__init__.py +8 -0
- itol_cli/account.py +161 -0
- itol_cli/aesthetics.py +248 -0
- itol_cli/cli.py +887 -0
- itol_cli/client.py +200 -0
- itol_cli/errors.py +56 -0
- itol_cli/learn.py +174 -0
- itol_cli/lint.py +250 -0
- itol_cli/render.py +137 -0
- itol_cli/templates.py +389 -0
- itol_cli/tree.py +507 -0
- itol_cli-0.2.0.dist-info/METADATA +97 -0
- itol_cli-0.2.0.dist-info/RECORD +16 -0
- itol_cli-0.2.0.dist-info/WHEEL +5 -0
- itol_cli-0.2.0.dist-info/entry_points.txt +2 -0
- itol_cli-0.2.0.dist-info/top_level.txt +1 -0
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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
@@ -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 @@
|
|
|
1
|
+
itol_cli
|