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/lint.py ADDED
@@ -0,0 +1,250 @@
1
+ """itol lint — iTOL 注解文件本地校验器(差异化护城河:人/AI 手写模板的第一道关)。
2
+
3
+ 校验规则基于官方模板实证(workflows/itol_research/agent2_formats.md + downloads/templates/):
4
+ - 首行关键字是模板锚(不是文件名!)
5
+ - 官方 CONNECTION 是单数;DOMAINS 不是 PROTEINDOMAINS
6
+ - 默认 SPACE 分隔;SEPARATOR 行可改 TAB/COMMA
7
+ - 内部节点一律 LCA 写法 ID1|ID2
8
+ - DATA 段前全是 KEY VALUE 控制行
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ # 官方模板类型(首行关键字,单一权威)——以 30 个官方模板实测全集为准(2026-11-02 校准)
17
+ KNOWN_KEYWORDS = {
18
+ # 树本身控制
19
+ "TREE_COLORS", "DATASET_STYLE", "MULTI_STYLE", "LABELS", "POPUP_INFO", "COLLAPSE", "PRUNE", "SPACING",
20
+ "AUTO_TAXONOMY", "METADATA",
21
+ # 数据集
22
+ "DATASET_COLORSTRIP", "DATASET_BINARY", "DATASET_GRADIENT", "DATASET_HEATMAP",
23
+ "DATASET_SYMBOL", "DATASET_EXTERNALSHAPE", "DATASET_DOMAINS", "DATASET_SIMPLEBAR",
24
+ "DATASET_MULTIBAR", "DATASET_BOXPLOT", "DATASET_LINECHART", "DATASET_PIECHART",
25
+ "DATASET_ALIGNMENT", "DATASET_CONNECTION", "DATASET_IMAGE", "DATASET_TEXT",
26
+ "DATASET_ARROWS", "DATASET_RANGE", "DATASET_TANGLEGRAM", "DATASET_TIMESCALE",
27
+ }
28
+ # 常见写错/别名(给诊断建议)
29
+ ALIASES = {
30
+ "DATASET_CONNECTIONS": "DATASET_CONNECTION(官方是单数!)",
31
+ "DATASET_PROTEINDOMAINS": "DATASET_DOMAINS",
32
+ "DATASET_COLOR_STRIP": "DATASET_COLORSTRIP",
33
+ "TREE_COLOR": "TREE_COLORS",
34
+ }
35
+ SEPARATORS = {"SPACE": None, "TAB": "\t", "COMMA": ","} # None = 任意空白
36
+ # 官方允许 hex / rgb() / rgba()(COLORSTRIP 模板实证:rgba(0,255,0,0.5) 合法)
37
+ HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
38
+ RGB_RE = re.compile(r"^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*[\d.]+\s*)?\)$", re.IGNORECASE)
39
+
40
+
41
+ def _is_color(s: str) -> bool:
42
+ return bool(HEX_RE.match(s) or RGB_RE.match(s))
43
+
44
+
45
+ @dataclass
46
+ class LintIssue:
47
+ level: str # "error" | "warning"
48
+ line: int # 0-based;-1 = 文件级
49
+ message: str
50
+
51
+
52
+ @dataclass
53
+ class LintReport:
54
+ path: str
55
+ keyword: str = ""
56
+ issues: list = field(default_factory=list)
57
+
58
+ @property
59
+ def errors(self):
60
+ return [i for i in self.issues if i.level == "error"]
61
+
62
+ @property
63
+ def warnings(self):
64
+ return [i for i in self.issues if i.level == "warning"]
65
+
66
+ @property
67
+ def ok(self) -> bool:
68
+ return not self.errors
69
+
70
+ def add(self, level: str, line: int, message: str) -> None:
71
+ self.issues.append(LintIssue(level, line, message))
72
+
73
+ def summary(self) -> str:
74
+ head = f"{'✅' if self.ok else '❌'} {self.path} (类型: {self.keyword or '未知'})"
75
+ lines = [f" L{i.line + 1}: [{i.level}] {i.message}" for i in self.issues]
76
+ return "\n".join([head] + lines)
77
+
78
+
79
+ def _detect_separator(header_lines: list[str]) -> tuple[str, str | None]:
80
+ for ln in header_lines:
81
+ parts = ln.split(None, 1)
82
+ if parts and parts[0].upper() == "SEPARATOR" and len(parts) == 2:
83
+ val = parts[1].strip().upper()
84
+ if val in SEPARATORS:
85
+ return val, SEPARATORS[val]
86
+ return val, None # 未知分隔符 → 按 SPACE 处理但告警
87
+ return "SPACE", None
88
+
89
+
90
+ def lint_file(path: Path) -> LintReport:
91
+ path = Path(path)
92
+ rep = LintReport(path=str(path))
93
+ if not path.is_file():
94
+ rep.add("error", -1, "文件不存在")
95
+ return rep
96
+ raw = path.read_text(encoding="utf-8", errors="replace")
97
+ lines = raw.splitlines()
98
+
99
+ # 1. 首行关键字
100
+ first = next((ln.strip() for ln in lines if ln.strip()), "")
101
+ if not first:
102
+ rep.add("error", -1, "空文件")
103
+ return rep
104
+ rep.keyword = first
105
+ if first not in KNOWN_KEYWORDS:
106
+ hint = ALIASES.get(first)
107
+ rep.add("error", 0, f"未知首行关键字 {first!r}" + (f"——你是不是想要 {hint}?" if hint else ""))
108
+
109
+ # 2. 找 DATA 段(官方模板示例形态可能连 DATA 标记行本身也被注释)
110
+ try:
111
+ data_idx = next(i for i, ln in enumerate(lines) if ln.strip().upper() == "DATA")
112
+ except StopIteration:
113
+ commented_data = next((i for i, ln in enumerate(lines) if ln.strip().lstrip("#").strip().upper() == "DATA"), None)
114
+ if commented_data is not None:
115
+ data_idx = commented_data
116
+ rep.add("warning", data_idx, "DATA 标记行被注释(官方模板示例形态)")
117
+ else:
118
+ rep.add("error", -1, "缺 DATA 段标记行(独立一行 'DATA')")
119
+ return rep
120
+ header = [ln for ln in lines[1:data_idx] if ln.strip() and not ln.strip().startswith("#")]
121
+ data = [ln for ln in lines[data_idx + 1:] if ln.strip() and not ln.strip().startswith("#")]
122
+ if not data:
123
+ # 官方模板形态:示例数据行全部被 # 注释——是模板示例不是真数据,降级 warning
124
+ commented = [ln for ln in lines[data_idx + 1:] if ln.strip().startswith("#")]
125
+ if commented:
126
+ rep.add("warning", data_idx, "DATA 段数据行全部被注释(官方模板示例形态;实数据文件须有非注释数据行)")
127
+ else:
128
+ rep.add("error", data_idx, "DATA 段为空")
129
+
130
+ # 3. 分隔符一致性
131
+ sep_name, sep = _detect_separator(header)
132
+ if sep_name not in SEPARATORS:
133
+ rep.add("warning", -1, f"未知 SEPARATOR {sep_name!r}(官方只有 SPACE/TAB/COMMA),按 SPACE 校验")
134
+ sep_name, sep = "SPACE", None
135
+
136
+ def split_row(ln: str) -> list[str]:
137
+ return ln.split(sep) if sep else ln.split()
138
+
139
+ # 4. 数据行校验(ALIGNMENT 的 DATA 是 FASTA 序列,不做表格列数检查)
140
+ widths = set()
141
+ if rep.keyword != "DATASET_ALIGNMENT":
142
+ for i, ln in enumerate(data):
143
+ row = split_row(ln)
144
+ lineno = data_idx + 1 + i
145
+ if len(row) < 2:
146
+ rep.add("error", lineno, f"数据行列数不足({len(row)} 列):{ln[:60]!r}")
147
+ continue
148
+ widths.add(len(row))
149
+ # 节点 ID 列非空 + 内部节点 LCA 写法检查
150
+ nid = row[0]
151
+ if not nid:
152
+ rep.add("error", lineno, "节点 ID 为空")
153
+ if sep == "," and "|" not in nid and " " in nid:
154
+ rep.add("error", lineno, f"COMMA 分隔下节点 ID 含空格:{nid!r}——内部节点请用 LCA 写法 ID1|ID2")
155
+
156
+ # 4.5 类型必填项(官方模板 MANDATORY SETTINGS 区实证:20/20 dataset_* 模板全有 DATASET_LABEL)
157
+ header_keys = {re.split(r"[\s,]+", ln, maxsplit=1)[0].upper() for ln in header}
158
+ if rep.keyword.startswith("DATASET_") and "DATASET_LABEL" not in header_keys:
159
+ rep.add("error", -1, f"{rep.keyword} 缺 MANDATORY 字段 DATASET_LABEL(官方模板必填)")
160
+ if rep.keyword == "DATASET_COLORSTRIP" and "COLOR" not in header_keys:
161
+ rep.add("warning", -1, "COLORSTRIP 建议带 COLOR(图例/默认色,官方模板 MANDATORY 区)")
162
+ if ("LEGEND_COLORS" in header_keys or "LEGEND_LABELS" in header_keys) and "LEGEND_SHAPES" not in header_keys:
163
+ rep.add("warning", -1, "图例规范=每行 shape+color+label:有 LEGEND_COLORS/LABELS 建议补 LEGEND_SHAPES")
164
+
165
+ # 5. 类型特定校验
166
+ if rep.keyword == "DATASET_COLORSTRIP":
167
+ for i, ln in enumerate(data):
168
+ row = split_row(ln)
169
+ if len(row) >= 2 and not _is_color(row[1]):
170
+ rep.add("error", data_idx + 1 + i, f"COLORSTRIP 第 2 列应为颜色(hex/rgb/rgba):{row[1]!r}")
171
+ if rep.keyword in ("DATASET_HEATMAP", "DATASET_MULTIBAR", "DATASET_BINARY"):
172
+ labels = None
173
+ for ln in header:
174
+ if ln.upper().startswith("FIELD_LABELS"):
175
+ labels = split_row(ln)[1:] if sep is None else ln.split(sep)[1].split(",")
176
+ if labels is None:
177
+ rep.add("warning", -1, f"{rep.keyword} 建议带 FIELD_LABELS(图例列名)")
178
+ elif widths and len(widths) == 1:
179
+ ncol = next(iter(widths)) - 1
180
+ if ncol != len(labels):
181
+ rep.add("error", -1, f"FIELD_LABELS 数({len(labels)})≠ 数据列数({ncol})")
182
+ if rep.keyword == "DATASET_CONNECTION":
183
+ for i, ln in enumerate(data):
184
+ row = split_row(ln)
185
+ lineno = data_idx + 1 + i
186
+ if len(row) < 2:
187
+ rep.add("error", lineno, f"CONNECTION 行需 ≥2 列(node1 node2…):{ln[:60]!r}")
188
+ continue
189
+ if "|" in row[0] or "|" in row[1]:
190
+ rep.add("warning", lineno, "CONNECTION 的节点通常是叶名对,LCA 写法确认是否符合预期")
191
+ if len(row) >= 3 and row[2]:
192
+ try:
193
+ float(row[2])
194
+ except ValueError:
195
+ rep.add("error", lineno, f"CONNECTION 第 3 列 WIDTH 须为数字:{row[2]!r}")
196
+ if len(row) >= 4 and row[3] and not _is_color(row[3]):
197
+ rep.add("error", lineno, f"CONNECTION 颜色非 hex/rgb/rgba:{row[3]!r}")
198
+ if len(row) >= 5 and row[4] and row[4] not in ("normal", "dashed", "dotted"):
199
+ rep.add("warning", lineno, f"CONNECTION 线型 {row[4]!r} 不在 normal/dashed/dotted 内")
200
+ if rep.keyword == "DATASET_RANGE":
201
+ for i, ln in enumerate(data):
202
+ row = split_row(ln)
203
+ if len(row) < 2:
204
+ rep.add("error", data_idx + 1 + i, f"RANGE 行需 ≥2 列(start_node,end_node…):{ln[:60]!r}")
205
+ if rep.keyword == "DATASET_TIMESCALE":
206
+ for i, ln in enumerate(data):
207
+ row = split_row(ln)
208
+ lineno = data_idx + 1 + i
209
+ if len(row) < 2:
210
+ rep.add("error", lineno, f"TIMESCALE 行需 ≥2 列(start end…):{ln[:60]!r}")
211
+ continue
212
+ try:
213
+ s, e = float(row[0]), float(row[1])
214
+ if s <= e:
215
+ rep.add("error", lineno, f"TIMESCALE 约定 start(老)>end(新),此行 {s}<={e}")
216
+ except ValueError:
217
+ rep.add("error", lineno, f"TIMESCALE 区间端点须为数字:{row[:2]!r}")
218
+ if rep.keyword == "DATASET_DOMAINS":
219
+ for i, ln in enumerate(data):
220
+ row = split_row(ln)
221
+ lineno = data_idx + 1 + i
222
+ if len(row) < 3:
223
+ rep.add("error", lineno, f"DOMAINS 数据行需 ≥3 列(ID/蛋白长度/域定义…):{ln[:60]!r}")
224
+ continue
225
+ try:
226
+ plen = int(row[1])
227
+ except ValueError:
228
+ rep.add("error", lineno, f"DOMAINS 第 2 列须为蛋白长度整数:{row[1]!r}")
229
+ continue
230
+ for d in row[2:]:
231
+ parts = d.split("|")
232
+ if len(parts) != 5:
233
+ rep.add("error", lineno, f"域定义需 5 段 SHAPE|START|END|COLOR|LABEL:{d!r}")
234
+ continue
235
+ try:
236
+ s, e = int(parts[1]), int(parts[2])
237
+ if s >= e:
238
+ rep.add("error", lineno, f"域 START>=END:{d!r}")
239
+ elif e > plen:
240
+ rep.add("error", lineno, f"域 END>{plen}(超蛋白长度):{d!r}")
241
+ except ValueError:
242
+ rep.add("error", lineno, f"域 START/END 须为整数:{d!r}")
243
+ if not _is_color(parts[3]):
244
+ rep.add("error", lineno, f"域颜色非 hex/rgb/rgba:{parts[3]!r}")
245
+
246
+ return rep
247
+
248
+
249
+ def lint_paths(paths: list[Path]) -> list[LintReport]:
250
+ return [lint_file(p) for p in paths]
itol_cli/render.py ADDED
@@ -0,0 +1,137 @@
1
+ """浏览器自动化渲染:Xvfb 虚拟显示 + playwright headful Chrome 驱动 iTOL 树页出带注解图。
2
+
3
+ 突破点(2026-11-04):iTOL 渲染引擎全在浏览器端,无头 SwiftShader 跑不动树页(~1MB JS),
4
+ 但 Xvfb 虚拟显示 + headful Chrome 可正常加载并 exportSVG。这是免费档出带注解图的唯一程序化路。
5
+
6
+ 坑位:DATASET_* 注解必须含 DATASET_LABEL 头行,否则 iTOL handler 静默拒绝注册
7
+ (错误只进 UI 解析日志面板,无异常无返回值)。本模块注入前自动补 + 注册后校验。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from .errors import ItolError
20
+
21
+ CHROME = "/usr/bin/google-chrome"
22
+
23
+ # 显示模式 → l1I1l.III111I 键值(1矩形/2环形/3无根)。JS 逆向实证:llII1ll(mode) 是官方设置函数
24
+ # (@26599,全代码库反复调用);@538204 实证 <100 叶强制矩形,≥100 叶默认环形。
25
+ DISPLAY_MODES = {"rect": 1, "circular": 2, "unrooted": 3}
26
+
27
+
28
+ def ensure_display(argv: list[str]) -> None:
29
+ """有 DISPLAY 直接返回;无则 xvfb-run 重跑本命令(结束后 SystemExit 替换当前进程)。"""
30
+ if os.environ.get("DISPLAY"):
31
+ return
32
+ if not shutil.which("xvfb-run"):
33
+ raise ItolError("IT006", "itol render 需显示环境或 xvfb(虚拟显示)",
34
+ diagnosis="apt install xvfb;或改在有显示的环境跑")
35
+ code = subprocess.call(["xvfb-run", "-a", "--server-args=-screen 0 1600x1200x24",
36
+ sys.executable, "-m", "itol_cli.cli", *argv])
37
+ raise SystemExit(code)
38
+
39
+
40
+ def _ensure_dataset_label(path: str) -> str:
41
+ """DATASET_* 文件缺 DATASET_LABEL 则补(生成器本应已带,这里是安全网)。返回文件内容。"""
42
+ txt = Path(path).read_text(encoding="utf-8")
43
+ lines = txt.splitlines()
44
+ if not lines or not lines[0].startswith("DATASET_"):
45
+ return txt # TREE_COLORS/LABELS 等非数据集类不需要
46
+ if "DATASET_LABEL" in "\n".join(lines[:20]):
47
+ return txt # 已有
48
+ lines.insert(1, f"DATASET_LABEL\t{Path(path).stem}")
49
+ return "\n".join(lines) + "\n"
50
+
51
+
52
+ def _status(msg: str) -> None:
53
+ """进度反馈(stderr,不污染 stdout 的 SVG 输出)——对标 csvtk/snakemake 的阶段提示。"""
54
+ import sys
55
+ print(f" … {msg}", file=sys.stderr, flush=True)
56
+
57
+
58
+ def render(tree_id: str, anno_files: list, out_svg: str | Path,
59
+ out_png: str | Path | None = None, wait: int = 60,
60
+ viewport: tuple[int, int] = (1600, 1200), mode: str | None = None) -> dict:
61
+ """核心渲染:加载树页 → 注入注解 → exportSVG。
62
+
63
+ 返回 {injected:{文件名:bool}, svg, svg_chars, pageerrors, png?, png_warn?, ok}。
64
+ ok = 全部注解注入确认;单个失败不抛(由 CLI 汇总),硬失败(模型未就绪/导出失败)抛 ItolError。
65
+ """
66
+ try:
67
+ from playwright.sync_api import Error as PWError
68
+ from playwright.sync_api import sync_playwright
69
+ except ImportError:
70
+ raise ItolError("IT006", "itol render 需要 playwright(浏览器自动化渲染路)",
71
+ diagnosis="pip install playwright") from None
72
+ url = f"https://itol.embl.de/tree/{tree_id}"
73
+ with sync_playwright() as p:
74
+ _status(f"启动浏览器(Xvfb+Chrome),打开 {url}")
75
+ browser = p.chromium.launch(headless=False, executable_path=CHROME,
76
+ args=["--no-sandbox", "--disable-dev-shm-usage"])
77
+ page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
78
+ errors: list[str] = []
79
+ page.on("pageerror", lambda e: errors.append(repr(e)[:200]))
80
+ try:
81
+ page.goto(url, timeout=90000, wait_until="domcontentloaded")
82
+ _status("树页已加载,等待数据模型就绪…")
83
+ ready = False
84
+ for _ in range(max(1, wait // 2)):
85
+ time.sleep(2)
86
+ try:
87
+ ready = bool(page.evaluate(
88
+ "() => typeof project!=='undefined'&&project&&"
89
+ "typeof project.exportSVG==='function'&&"
90
+ "typeof treeData!=='undefined'&&typeof lI1lll1==='function'"))
91
+ except PWError:
92
+ ready = False # 轮询期间瞬时 JS/上下文错误=未就绪,继续等
93
+ if ready:
94
+ break
95
+ if not ready:
96
+ raise ItolError("IT003", f"iTOL 树页数据模型 {wait}s 未就绪",
97
+ diagnosis="查树 ID 有效性/网络;大树(千叶级)可加 --wait")
98
+ _status("数据模型就绪,注入注解…")
99
+ # 显示模式切换(官方函数 llII1ll,含重算+重绘)
100
+ if mode:
101
+ _status(f"切换显示模式:{mode}")
102
+ page.evaluate("(m) => llII1ll(m)", DISPLAY_MODES[mode])
103
+ time.sleep(2) # 等模式切换重渲
104
+ injected: dict[str, bool] = {}
105
+ for f in anno_files:
106
+ b64 = base64.b64encode(_ensure_dataset_label(f).encode()).decode()
107
+ pre = page.evaluate("() => Object.keys(currentData.info.datasets||{}).length")
108
+ r = page.evaluate(
109
+ """(b64) => { try { lI1lll1({name:'a.txt',type:'text/plain',
110
+ data:'data:text/plain;base64,'+b64}); return 'ok'; }
111
+ catch(e){ return 'ERR '+e; } }""", b64)
112
+ time.sleep(1.5)
113
+ post = page.evaluate("() => Object.keys(currentData.info.datasets||{}).length")
114
+ tcp = page.evaluate("() => !!tree_changes_pending")
115
+ injected[Path(f).name] = (r == "ok") and (post > pre or tcp)
116
+ time.sleep(2) # flush 渲染循环
117
+ _status("导出 SVG…")
118
+ svg = page.evaluate(
119
+ """() => { try { var a=project.exportSVG();
120
+ return new XMLSerializer().serializeToString(a); }
121
+ catch(e){ return 'ERR '+e; } }""")
122
+ if not (isinstance(svg, str) and svg.startswith("<")):
123
+ raise ItolError("IT003", f"exportSVG 失败:{str(svg)[:200]}")
124
+ Path(out_svg).write_text(svg, encoding="utf-8")
125
+ result: dict = {"injected": injected, "svg": str(out_svg),
126
+ "svg_chars": len(svg), "pageerrors": len(errors),
127
+ "ok": all(injected.values())}
128
+ if out_png:
129
+ if shutil.which("rsvg-convert"):
130
+ subprocess.run(["rsvg-convert", "-b", "white", "-w", "1600", str(out_svg), "-o", str(out_png)],
131
+ check=True, capture_output=True)
132
+ result["png"] = str(out_png)
133
+ else:
134
+ result["png_warn"] = "rsvg-convert 未装,跳过 PNG(apt install librsvg2-bin)"
135
+ return result
136
+ finally:
137
+ browser.close()