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/client.py ADDED
@@ -0,0 +1,200 @@
1
+ """iTOL 三个 CGI 端点的客户端(调研实证规格,workflows/itol_research/agent1_api.md)。
2
+
3
+ 端点事实(2026-11-02 curl 实测):
4
+ - 上传 batch_uploader.cgi:POST,必须 APIkey(付费订阅)→ text/plain 首行 SUCCESS: <treeID> / ERR<code>
5
+ - 导出 batch_downloader.cgi:GET/POST,免认证;成功=二进制 MIME,失败=text/html 或 text/plain 错误
6
+ - 删除 batch_delete.cgi:POST,APIkey
7
+ - 页面型 URL(/tree/<id>)无效 ID 也回 200——必须解析 <title> 含 "Error"
8
+ - 官方无速率上限 → 串行 + 间隔 + 指数退避;导出走服务器队列,超时 ≥120s
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import re
14
+ import time
15
+ import zipfile
16
+ from pathlib import Path
17
+
18
+ import requests
19
+
20
+ from .errors import auth_required, export_failed, upload_failed
21
+
22
+ UPLOAD_URL = "https://itol.embl.de/batch_uploader.cgi"
23
+ EXPORT_URL = "https://itol.embl.de/batch_downloader.cgi"
24
+ DELETE_URL = "https://itol.embl.de/batch_delete.cgi"
25
+ LOADTREE_URL = "https://itol.embl.de/ajax/loadTree.cgi" # 免认证拉公开树结构+元数据(hack4 实证)
26
+ TREE_PAGE = "https://itol.embl.de/tree/{tid}"
27
+
28
+ EXPORT_FORMATS = ("svg", "eps", "pdf", "png", "newick", "nexus", "phyloxml")
29
+
30
+ # 导出参数预设(2026-11-04 深挖 83 个 batch_downloader 参数后固化,全表见 docs/EXPORT_PARAMS.md)
31
+ # --preset 可叠加,--param key=value 优先级更高(后者覆盖前者同键)
32
+ EXPORT_PRESETS = {
33
+ "publication": {"align_labels": "1", "dashed_lines": "1", "current_font_size": "14"},
34
+ "circular": {"display_mode": "2"},
35
+ "unrooted": {"display_mode": "3"},
36
+ "cladogram": {"ignore_branch_length": "1"},
37
+ "slanted": {"display_mode": "1", "ignore_branch_length": "1", "slanted_normal": "1"},
38
+ "bootstrap": {"bootstrap_display": "1", "bootstrap_type": "1"},
39
+ "bootstrap-color": {"bootstrap_display": "1", "bootstrap_type": "3",
40
+ "bootstrap_min_color": "#0000ff", "bootstrap_max_color": "#ff0000"},
41
+ "bootstrap-width": {"bootstrap_display": "1", "bootstrap_type": "4"},
42
+ "timescale": {"internal_scale": "1", "internalScaleAxis": "1", "internalScaleGrid": "1"},
43
+ }
44
+ # 导出成功时的二进制 MIME(实测规格);text/html|text/plain = 失败
45
+ _EXPORT_OK_MIME = ("image/", "application/pdf", "application/postscript", "application/octet-stream")
46
+
47
+ SUCCESS_RE = re.compile(r"^SUCCESS:\s*(\d+)", re.MULTILINE)
48
+ ERR_RE = re.compile(r"^ERR\s*(\d+)\s*(.*)")
49
+
50
+
51
+ def make_upload_zip(tree_file: Path, annotation_files: list[Path]) -> bytes:
52
+ """打包树+注解为 zip(树文件扩展名必须 .tree/.tree.txt/.jplace——官方规格)。"""
53
+ tree_file = Path(tree_file)
54
+ name = tree_file.name
55
+ if not name.endswith((".tree", ".tree.txt", ".jplace")):
56
+ # 官方硬性要求;自动补 .tree 扩展名
57
+ name = name + ".tree"
58
+ buf = io.BytesIO()
59
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
60
+ zf.writestr(name, tree_file.read_bytes())
61
+ for af in annotation_files:
62
+ zf.writestr(Path(af).name, Path(af).read_bytes())
63
+ return buf.getvalue()
64
+
65
+
66
+ class ItolClient:
67
+ """iTOL API client。serial 请求 + 指数退避;错误分层解析。"""
68
+
69
+ def __init__(self, api_key: str | None = None, min_interval: float = 1.0, timeout: float = 120.0):
70
+ self.api_key = api_key or ""
71
+ self.min_interval = min_interval
72
+ self.timeout = timeout
73
+ self._last_call = 0.0
74
+ self.session = requests.Session()
75
+ self.session.headers["User-Agent"] = "itol-cli/0.1 (+https://github.com/; academic)"
76
+ # ---- 内部 ----
77
+ def _throttle(self) -> None:
78
+ delta = time.time() - self._last_call
79
+ if delta < self.min_interval:
80
+ time.sleep(self.min_interval - delta)
81
+ self._last_call = time.time()
82
+
83
+ def _retry(self, fn, attempts: int = 3):
84
+ delay = 2.0
85
+ last_exc: Exception | None = None
86
+ for i in range(attempts):
87
+ try:
88
+ return fn()
89
+ except requests.RequestException as e: # 网络层错误可重试
90
+ last_exc = e
91
+ if i < attempts - 1:
92
+ time.sleep(delay)
93
+ delay *= 2
94
+ raise last_exc # type: ignore[misc]
95
+
96
+ @staticmethod
97
+ def _parse_upload_response(text: str) -> tuple[str, list[str]]:
98
+ """返回 (tree_id, warnings);失败抛 IT002。"""
99
+ m = SUCCESS_RE.search(text)
100
+ if m:
101
+ warnings = [ln for ln in text.splitlines() if ln.strip() and not ln.startswith("SUCCESS")]
102
+ return m.group(1), warnings
103
+ em = ERR_RE.search(text)
104
+ if em:
105
+ raise upload_failed(f"iTOL ERR {em.group(1)}: {em.group(2).strip()}",
106
+ diagnosis="检查 zip 内容(树扩展名/注解语法)与 projectName(大小写敏感、须 Web UI 预建)")
107
+ raise upload_failed(f"未知上传响应:{text[:200]!r}")
108
+
109
+ # ---- 上传(需订阅) ----
110
+ def upload(self, tree_file: Path, annotation_files: list[Path], project: str,
111
+ tree_name: str = "", description: str = "") -> tuple[str, list[str]]:
112
+ if not self.api_key:
113
+ raise auth_required("批量上传")
114
+ payload = make_upload_zip(Path(tree_file), [Path(p) for p in annotation_files])
115
+
116
+ def call():
117
+ self._throttle()
118
+ return self.session.post(
119
+ UPLOAD_URL,
120
+ files={"zipFile": ("upload.zip", payload, "application/zip")},
121
+ data={"APIkey": self.api_key, "projectName": project,
122
+ "treeName": tree_name, "treeDescription": description},
123
+ timeout=self.timeout,
124
+ )
125
+
126
+ resp = self._retry(call)
127
+ return self._parse_upload_response(resp.text)
128
+
129
+ # ---- 导出(免认证) ----
130
+ def export(self, tree_id: str, fmt: str, out: Path | None = None, **params) -> bytes:
131
+ """导出树图。fmt ∈ EXPORT_FORMATS;params 透传 batch_downloader 全部显示参数。
132
+
133
+ 注意(调研实证):无 dpi/宽高参数——高分辨率位图是缺口,默认引导矢量格式。
134
+ """
135
+ fmt = fmt.lower()
136
+ if fmt not in EXPORT_FORMATS:
137
+ raise export_failed(f"格式 {fmt!r} 不支持", diagnosis=f"可用:{','.join(EXPORT_FORMATS)}")
138
+ q = {"tree": tree_id, "format": fmt}
139
+ q.update(params)
140
+
141
+ def call():
142
+ self._throttle()
143
+ return self.session.get(EXPORT_URL, params=q, timeout=self.timeout)
144
+
145
+ resp = self._retry(call)
146
+ ct = resp.headers.get("Content-Type", "")
147
+ if any(ct.startswith(m) for m in _EXPORT_OK_MIME):
148
+ data = resp.content
149
+ if out:
150
+ Path(out).write_bytes(data)
151
+ return data
152
+ # 失败:text/html 或 text/plain 错误文本
153
+ text = resp.text[:300]
154
+ raise export_failed(f"导出失败(HTTP {resp.status_code}):{text.strip()[:160]}",
155
+ diagnosis="确认 tree ID 存在(itol status <id>);格式见 itol export --help")
156
+
157
+ # ---- 删除(需订阅) ----
158
+ def delete(self, tree_id: str) -> None:
159
+ if not self.api_key:
160
+ raise auth_required("删除树")
161
+
162
+ def call():
163
+ self._throttle()
164
+ return self.session.post(DELETE_URL, data={"APIkey": self.api_key, "tree": tree_id},
165
+ timeout=self.timeout)
166
+
167
+ resp = self._retry(call)
168
+ if not SUCCESS_RE.search(resp.text):
169
+ em = ERR_RE.search(resp.text)
170
+ msg = em.group(2).strip() if em else resp.text[:200]
171
+ raise upload_failed(f"删除失败:{msg}")
172
+
173
+ # ---- 状态检查(页面型 URL 陷阱:无效 ID 也 200,须解析 title) ----
174
+ def fetch_tree(self, tree_id: str, dataset_ids: str | None = None) -> dict:
175
+ """POST loadTree.cgi 拉公开树的完整结构+元数据(免认证,hack4 实证)。
176
+
177
+ 返回 JSON:node(节点索引 par/kids/bl/lf)/ param(root/has_brl/tree_height)/
178
+ info(names/label_color/node_metadata/tree_name/datasets/...)/ leaf(叶序)。
179
+ """
180
+ def call():
181
+ data = {"tree": tree_id}
182
+ if dataset_ids:
183
+ data["batch"] = "1"
184
+ data["ds"] = dataset_ids
185
+ return self.session.post(LOADTREE_URL, data=data, timeout=self.timeout)
186
+ r = self._retry(call)
187
+ try:
188
+ return r.json()
189
+ except ValueError:
190
+ raise export_failed(f"loadTree 响应非 JSON:{r.text[:150]!r}") from None
191
+
192
+ def tree_exists(self, tree_id: str) -> bool:
193
+ def call():
194
+ self._throttle()
195
+ return self.session.get(TREE_PAGE.format(tid=tree_id), timeout=self.timeout)
196
+
197
+ resp = self._retry(call)
198
+ m = re.search(r"<title>(.*?)</title>", resp.text, re.DOTALL)
199
+ title = m.group(1) if m else ""
200
+ return "Error" not in title and "Invalid tree ID" not in resp.text
itol_cli/errors.py ADDED
@@ -0,0 +1,56 @@
1
+ """itol-cli 错误契约(裁剪自 tbtools-cli TB001-012 模式)。
2
+
3
+ 每个错误码带 retryable 布尔 + 诊断字段。Agent/人都能据此决策下一步。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class ItolError(Exception):
12
+ code: str
13
+ message: str
14
+ retryable: bool = False
15
+ diagnosis: str = ""
16
+ details: dict = field(default_factory=dict)
17
+
18
+ def __str__(self) -> str: # pragma: no cover
19
+ base = f"[{self.code}] {self.message}"
20
+ return f"{base}(建议:{self.diagnosis})" if self.diagnosis else base
21
+
22
+
23
+ # 错误码注册表(单一权威,文档/测试从这里生成)
24
+ ERROR_CODES = {
25
+ "IT001": "INVALID_INPUT — 输入文件/注解文件不合法(lint 可本地拦截)",
26
+ "IT002": "UPLOAD_FAILED — 上传失败(ERR<code> 或网络);订阅/key 问题归 IT004",
27
+ "IT003": "EXPORT_FAILED — 导出失败(无效树 ID / 格式不支持 / 服务器队列超时)",
28
+ "IT004": "AUTH_REQUIRED — 功能需 iTOL 标准订阅 + APIkey(userInfo.cgi 生成)",
29
+ "IT005": "RATE_LIMITED — 触发服务端限速(官方未明示阈值;串行+退避规避)",
30
+ "IT006": "INTERNAL — 内部错误(框架 bug;带 traceback 报 issue)",
31
+ }
32
+
33
+
34
+ def invalid_input(message: str, diagnosis: str = "") -> ItolError:
35
+ return ItolError("IT001", message, retryable=False, diagnosis=diagnosis or "跑 itol lint <文件> 定位")
36
+
37
+
38
+ def upload_failed(message: str, retryable: bool = False, diagnosis: str = "") -> ItolError:
39
+ return ItolError("IT002", message, retryable=retryable, diagnosis=diagnosis)
40
+
41
+
42
+ def export_failed(message: str, retryable: bool = False, diagnosis: str = "") -> ItolError:
43
+ return ItolError("IT003", message, retryable=retryable, diagnosis=diagnosis)
44
+
45
+
46
+ def auth_required(what: str = "此功能") -> ItolError:
47
+ return ItolError(
48
+ "IT004",
49
+ f"{what}需要 iTOL 标准订阅 + APIkey",
50
+ retryable=False,
51
+ diagnosis="订阅见 https://itol.embl.de/pricing.cgi;key 在 userInfo.cgi 生成;配置 ITOL_API_KEY 环境变量或 ~/.itol-cli/config",
52
+ )
53
+
54
+
55
+ def rate_limited(message: str = "疑似触发服务端限速") -> ItolError:
56
+ return ItolError("IT005", message, retryable=True, diagnosis="串行请求 + 间隔≥1s + 指数退避")
itol_cli/learn.py ADDED
@@ -0,0 +1,174 @@
1
+ """itol learn —— 模板逆向复用(对标 itol.toolkit 主题学习)。
2
+
3
+ 解析已有 iTOL 注解文件 → 抽出 header 区样式参数 → 复用到新数据。
4
+ 场景:看到别人论文的 iTOL 图想复同样式;或把自己的旧图样式套到新数据。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from .errors import invalid_input
12
+
13
+ # 已知的 iTOL 注解关键字(lint.py 同款集合,learn 用它识别首行类型)
14
+ _KNOWN_KEYWORDS = {
15
+ "TREE_COLORS", "DATASET_STYLE", "MULTI_STYLE", "LABELS", "POPUP_INFO", "COLLAPSE", "PRUNE", "SPACING",
16
+ "DATASET_COLORSTRIP", "DATASET_BINARY", "DATASET_GRADIENT", "DATASET_HEATMAP", "DATASET_SYMBOL",
17
+ "DATASET_EXTERNALSHAPE", "DATASET_DOMAINS", "DATASET_SIMPLEBAR", "DATASET_MULTIBAR", "DATASET_BOXPLOT",
18
+ "DATASET_LINECHART", "DATASET_PIECHART", "DATASET_ALIGNMENT", "DATASET_CONNECTION", "DATASET_IMAGE",
19
+ "DATASET_TEXT", "DATASET_ARROWS", "DATASET_RANGE", "DATASET_TANGLEGRAM", "DATASET_TIMESCALE", "DATASET_SCALE",
20
+ "DATASET_LABEL", "DATASET_COLORBRANCH", "DATASET_COLORSHAPE", "DATASET_ANNOTATION",
21
+ }
22
+
23
+ _SEP_NAMES = {"TAB": "\t", "SPACE": " ", "COMMA": ","}
24
+
25
+
26
+ def learn_style(path: Path) -> dict:
27
+ """解析 iTOL 注解文件,抽出样式参数(header 区 = DATA 行之前的所有内容)。
28
+
29
+ 返回 dict:keyword / separator / header_params(有序 list of (key, value))/ n_data_rows。
30
+ """
31
+ lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
32
+ if not lines:
33
+ raise invalid_input(f"{path} 为空")
34
+ keyword = ""
35
+ for ln in lines:
36
+ ls = ln.strip()
37
+ if ls and not ls.startswith("#"):
38
+ keyword = ls.split()[0].upper()
39
+ break
40
+ if keyword not in _KNOWN_KEYWORDS:
41
+ raise invalid_input(f"{path} 首行不是已知 iTOL 关键字(读到 {keyword!r})——跑 itol lint 看看")
42
+ sep = "\t"
43
+ header: list[tuple[str, str]] = []
44
+ n_data = 0
45
+ in_data = False
46
+ for ln in lines:
47
+ ls = ln.strip()
48
+ if not in_data:
49
+ if ls.upper() == "DATA":
50
+ in_data = True
51
+ continue
52
+ if not ls or ls.startswith("#"):
53
+ continue
54
+ # header 参数行:KEY<sep>value
55
+ parts = ln.split("\t") if "\t" in ln else ln.split(None, 1)
56
+ if not parts:
57
+ continue
58
+ key = parts[0].strip()
59
+ val = parts[1].strip() if len(parts) > 1 else ""
60
+ if key.upper() == "SEPARATOR":
61
+ sep = _SEP_NAMES.get(val.upper(), "\t")
62
+ header.append((key, val))
63
+ else:
64
+ if ls and not ls.startswith("#"):
65
+ n_data += 1
66
+ return {
67
+ "keyword": keyword,
68
+ "separator": {v: k for k, v in _SEP_NAMES.items()}.get(sep, "TAB"),
69
+ "header_params": header,
70
+ "n_data_rows": n_data,
71
+ "source": str(path),
72
+ }
73
+
74
+
75
+ def apply_style(style: dict, new_data: Path, out: Path) -> Path:
76
+ """把学到的 header 样式套到新数据文件(new_data 的每行原样进 DATA 区)。
77
+
78
+ new_data 应已符合该数据集类型的列结构(learn 只管样式,不管语义映射)。
79
+ """
80
+ keyword = style["keyword"]
81
+ sep = _SEP_NAMES.get(style.get("separator", "TAB"), "\t")
82
+ lines = [keyword]
83
+ for key, val in style["header_params"]:
84
+ if key == keyword: # 首行关键字已写
85
+ continue
86
+ lines.append(f"{key}{sep}{val}" if val else key)
87
+ lines.append("DATA")
88
+ data_lines = [ln for ln in Path(new_data).read_text(encoding="utf-8").splitlines()
89
+ if ln.strip() and not ln.startswith("#")]
90
+ lines.extend(data_lines)
91
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
92
+ return out
93
+
94
+
95
+ def style_to_json(style: dict) -> str:
96
+ return json.dumps(style, ensure_ascii=False, indent=2)
97
+
98
+
99
+ def style_from_json(text: str) -> dict:
100
+ d = json.loads(text)
101
+ if "keyword" not in d or "header_params" not in d:
102
+ raise invalid_input("样式 JSON 缺 keyword/header_params 字段")
103
+ return d
104
+
105
+
106
+ # ── 图片样式逆向(用户拖入「认为美观」的图,复刻其参数)────────────────────────────
107
+ def learn_svg(path: Path) -> dict:
108
+ """从 SVG 矢量图反推样式参数(高保真,零依赖)。
109
+
110
+ SVG 是结构化 XML:颜色/字号/几何/布局全在标签里。提取调色板(用频排序)、
111
+ 字号、布局(矩形/环形)、叶数、枝色 → style dict(兼容 apply_style)。
112
+ """
113
+ import re
114
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
115
+ # 调色板:收集 fill/stroke 的非黑白灰色,按频次排序
116
+ colors: dict[str, int] = {}
117
+ for m in re.finditer(r'(?:fill|stroke)="([^"]+)"', text):
118
+ c = m.group(1).strip().lower()
119
+ if c.startswith("#") and c not in ("#000", "#000000", "#fff", "#ffffff", "#888888", "#ccc", "#333", "#666", "#999"):
120
+ colors[c] = colors.get(c, 0) + 1
121
+ palette = [c for c, _ in sorted(colors.items(), key=lambda x: -x[1])]
122
+ # 字号:font-size 属性
123
+ fonts = [float(m.group(1)) for m in re.finditer(r'font-size="?([\d.]+)', text)]
124
+ font_pt = round(sum(fonts) / len(fonts), 1) if fonts else None
125
+ # 布局:环形=有圆弧命令或 rotate 变换占多;矩形=直线为主
126
+ n_arc = len(re.findall(r'[d]\s*=\s*"[^"]*[aA][^"]*"', text))
127
+ n_line = text.count("<line") + text.count("<polyline")
128
+ layout = "circular" if n_arc > n_line * 0.3 and n_arc > 3 else "rect"
129
+ # 叶数:<text> 标签数(近似)
130
+ n_labels = len(re.findall(r"<text", text))
131
+ return {
132
+ "source_image": str(path),
133
+ "palette": palette[:12],
134
+ "n_colors": len(palette),
135
+ "font_pt": font_pt,
136
+ "layout": layout,
137
+ "n_labels": n_labels,
138
+ "branch_colors": [c for c in palette if c],
139
+ }
140
+
141
+
142
+ def learn_image(path: Path) -> dict:
143
+ """从图片反推样式参数。.svg → 全解析;.png/.jpg → 主色调色板+布局(需 PIL)。
144
+
145
+ 返回 style dict + 来源说明。PNG 需 pillow(pip install pillow)。
146
+ """
147
+ suffix = Path(path).suffix.lower()
148
+ if suffix == ".svg":
149
+ d = learn_svg(path)
150
+ d["method"] = "svg 全解析(高保真)"
151
+ return d
152
+ if suffix in (".png", ".jpg", ".jpeg"):
153
+ try:
154
+ from PIL import Image # type: ignore
155
+ except ImportError:
156
+ raise invalid_input(
157
+ "PNG/JPG 图片样式提取需 pillow(SVG 无需)。装一下:pip install pillow;或改用 SVG 版图片(高保真)") from None
158
+ img = Image.open(path).convert("RGB")
159
+ img.thumbnail((200, 200)) # 缩小加速
160
+ # 主色提取:quantize 聚类
161
+ q = img.quantize(colors=10)
162
+ pal = q.getpalette()[:30]
163
+ counts = sorted(q.getcolors(), reverse=True)
164
+ palette = []
165
+ for cnt, idx in counts:
166
+ r, g, b = pal[idx * 3:idx * 3 + 3]
167
+ if (r, g, b) not in ((255, 255, 255), (0, 0, 0)) and not (r > 230 and g > 230 and b > 230):
168
+ palette.append(f"#{r:02x}{g:02x}{b:02x}")
169
+ w, h = Image.open(path).size
170
+ layout = "circular" if abs(w - h) / max(w, h) < 0.15 else "rect" # 方形画布≈环形
171
+ return {"source_image": str(path), "method": "png 主色提取(近似)", "palette": palette[:12],
172
+ "n_colors": len(palette), "layout": layout,
173
+ "note": "PNG 只能拿调色板+布局;字号/间距建议用 SVG 版复刻"}
174
+ raise invalid_input(f"不支持的图片格式 {suffix}(支持 .svg / .png / .jpg)")