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 ADDED
@@ -0,0 +1,8 @@
1
+ """itol-cli: iTOL annotation template generation + upload/export pipeline."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("itol-cli")
7
+ except PackageNotFoundError: # 未安装态(源码直跑)
8
+ __version__ = "0.2.0"
itol_cli/account.py ADDED
@@ -0,0 +1,161 @@
1
+ """账号通路:iTOL 登录 / 工作区 / 树文本上传(免费账号可用,源码实证端点)。
2
+
3
+ 与 client.py(订阅制 batch 三 CGI)互补:
4
+ - 登录:login.cgi {login,pwd,submit} → itolsession cookie(约 2h 有效)
5
+ - 工作区:personal_page.cgi 内嵌 `var ws=[...]` JSON(Workspace→Project→Tree 三层)
6
+ - 树上传(文本):POST /ajax/personal/tree_uploader.cgi {pid,tNm,tTxt} → JSON 树 ID
7
+
8
+ ⚠️ 注解挂树无服务端端点(iTOL 渲染引擎全在浏览器端,FileReader 客户端读入)——
9
+ 本模块不含注解挂树;出带注解图需订阅 batch_uploader 或浏览器自动化。
10
+ """
11
+ import json
12
+ import re
13
+ import time
14
+ from http.cookiejar import LoadError, MozillaCookieJar
15
+ from pathlib import Path
16
+
17
+ import requests
18
+
19
+ from .errors import ItolError
20
+
21
+ LOGIN_URL = "https://itol.embl.de/login.cgi"
22
+ PERSONAL_URL = "https://itol.embl.de/personal_page.cgi"
23
+ TREE_UPLOADER_URL = "https://itol.embl.de/ajax/personal/tree_uploader.cgi"
24
+ EXPORT_CGI_URL = "https://itol.embl.de/ajax/export.cgi"
25
+ EXPORT_QUEUE_URL = "https://itol.embl.de/ajax/exportQueueCheck.cgi"
26
+ DOWNLOAD_URL = "https://itol.embl.de/download.cgi"
27
+ CONFIG_DIR = Path.home() / ".itol-cli"
28
+ CONFIG_FILE = CONFIG_DIR / "config"
29
+ SESSION_FILE = CONFIG_DIR / "session_cookies"
30
+
31
+
32
+ def load_config() -> dict:
33
+ """读 ~/.itol-cli/config(KEY=VALUE 行式;权限应 600)。"""
34
+ cfg = {}
35
+ if CONFIG_FILE.is_file():
36
+ for ln in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
37
+ ln = ln.strip()
38
+ if ln and not ln.startswith("#") and "=" in ln:
39
+ k, _, v = ln.partition("=")
40
+ cfg[k.strip()] = v.strip()
41
+ return cfg
42
+
43
+
44
+ class AccountClient:
45
+ """iTOL 账号会话客户端(免费账号可用部分)。"""
46
+
47
+ def __init__(self) -> None:
48
+ self.session = requests.Session()
49
+ self.session.headers["User-Agent"] = "itol-cli/0.1 (+https://github.com/; academic)"
50
+ if SESSION_FILE.is_file():
51
+ jar = MozillaCookieJar(str(SESSION_FILE))
52
+ try:
53
+ jar.load(ignore_discard=True, ignore_expires=True)
54
+ self.session.cookies = jar # type: ignore[assignment]
55
+ except (OSError, LoadError):
56
+ pass
57
+
58
+ def _save_session(self) -> None:
59
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
60
+ jar = MozillaCookieJar(str(SESSION_FILE))
61
+ for c in self.session.cookies:
62
+ jar.set_cookie(c)
63
+ jar.save(ignore_discard=True, ignore_expires=True)
64
+ SESSION_FILE.chmod(0o600)
65
+
66
+ # ---- 登录 ----
67
+ def login(self, user: str, password: str) -> str:
68
+ """POST login.cgi → itolsession cookie;返回登录用户名。失败抛 ItolError(IT004)。"""
69
+ self.session.post(LOGIN_URL, data={"login": user, "pwd": password, "submit": "Sign in"},
70
+ allow_redirects=False, timeout=30)
71
+ if not self.logged_in():
72
+ raise ItolError("IT004", "登录失败:账号或密码错误(响应无会话 cookie)")
73
+ self._save_session()
74
+ return user
75
+
76
+ def logged_in(self) -> bool:
77
+ # MozillaCookieJar 迭代出 Cookie 对象而非名字,必须用名字匹配(「in jar」会永远 False)
78
+ return any(c.name == "itolsession" for c in self.session.cookies)
79
+
80
+ def whoami(self) -> str:
81
+ """会话里取用户名(personal_page.cgi 顶栏 hdrUname);未登录返回空串。"""
82
+ if not self.logged_in():
83
+ return ""
84
+ r = self.session.get(PERSONAL_URL, timeout=30)
85
+ m = re.search(r"id='hdrUname'[^>]*>([^<]+)<", r.text)
86
+ return m.group(1).strip() if m else ""
87
+
88
+ # ---- 工作区 ----
89
+ def workspaces(self) -> list:
90
+ """解析 personal_page.cgi 内嵌 var ws=[...] JSON → 工作区列表。"""
91
+ if not self.logged_in():
92
+ raise ItolError("IT004", "未登录(先 itol login)")
93
+ r = self.session.get(PERSONAL_URL, timeout=30)
94
+ m = re.search(r"var ws = (\[.*?\]); var views", r.text, re.DOTALL)
95
+ if not m:
96
+ raise ItolError("IT005", "工作区数据解析失败(personal_page.cgi 结构变更?)")
97
+ return json.loads(m.group(1))
98
+
99
+ def trees(self) -> list:
100
+ """扁平化全部 (workspace, project, tree) 元组。"""
101
+ out = []
102
+ for w in self.workspaces():
103
+ for p in w.get("p", []):
104
+ for t in p.get("data", []):
105
+ out.append((w.get("t", ""), p.get("t", ""), p.get("id", ""), t.get("t", ""), t.get("id", "")))
106
+ return out
107
+
108
+ # ---- 树上传(文本模式,免费账号可用)----
109
+ def upload_tree_text(self, project_id: str, name: str, newick: str) -> str:
110
+ """POST tree_uploader.cgi {pid,tNm,tTxt} → 树 ID(实证免费账号可用)。"""
111
+ if not self.logged_in():
112
+ raise ItolError("IT004", "未登录(先 itol login)")
113
+ if len(newick.strip()) < 5:
114
+ raise ItolError("IT001", "树文本过短(<5 字符)")
115
+ r = self.session.post(TREE_UPLOADER_URL,
116
+ data={"pid": project_id, "tNm": name, "tTxt": newick}, timeout=60)
117
+ try:
118
+ d = r.json()
119
+ except ValueError:
120
+ raise ItolError("IT005", f"上传响应非 JSON:{r.text[:120]!r}") from None
121
+ tid = d.get("id") or (d.get("tree") or {}).get("id")
122
+ if not tid:
123
+ raise ItolError("IT005", f"上传未返回树 ID:{d!r}")
124
+ return str(tid)
125
+
126
+ # ---- 高分辨率位图导出(export.cgi 异步队列,免费账号实证可用)----
127
+ def export_hires_png(self, svg_text: str, tree_id: str, dpi: int = 300,
128
+ margin: int = 0, full_image: bool = True, wait: int = 120) -> bytes:
129
+ """POST export.cgi(q=SVG XML, dpi≤1000)→ 轮询队列 → download.cgi 取高 DPI PNG。
130
+
131
+ 2026-11-05 黑客线 E2E 实证:export.cgi 队列是补 batch_downloader 无 dpi 缺口的路。
132
+ 同会话一次只能排一个导出任务;fmt=png 唯一位图格式。
133
+ """
134
+ if not self.logged_in():
135
+ raise ItolError("IT004", "高分辨率导出需登录(先 itol login)")
136
+ r = self.session.post(EXPORT_CGI_URL, data={
137
+ "id": tree_id, "nm": "itol-cli", "shared": "0", "fmt": "png",
138
+ "dpi": str(dpi), "m": str(margin), "a": "1" if full_image else "0",
139
+ "q": svg_text}, timeout=60)
140
+ try:
141
+ j = r.json()
142
+ except ValueError:
143
+ raise ItolError("IT005", f"export.cgi 响应非 JSON:{r.text[:150]!r}") from None
144
+ qid = j.get("queueID")
145
+ if not qid:
146
+ raise ItolError("IT003", f"export.cgi 未入队:{j!r}")
147
+ for _ in range(max(1, wait // 5)):
148
+ time.sleep(5)
149
+ chk = self.session.post(EXPORT_QUEUE_URL, data={"id": qid}, timeout=30)
150
+ try:
151
+ cj = chk.json()
152
+ except ValueError:
153
+ continue
154
+ if cj.get("file"):
155
+ d = self.session.get(DOWNLOAD_URL, params={"f": cj["file"]}, timeout=60)
156
+ if d.content[:4] == b"\x89PNG":
157
+ return d.content
158
+ raise ItolError("IT003", f"下载结果非 PNG:{d.text[:150]!r}")
159
+ if cj.get("inQueue") == -1:
160
+ raise ItolError("IT003", "export.cgi 转换失败(队列返回 inQueue=-1)")
161
+ raise ItolError("IT003", f"高分辨率导出超时(队列 {wait}s 未完成)")
itol_cli/aesthetics.py ADDED
@@ -0,0 +1,248 @@
1
+ """美学自适应默认——把文献锚定的树图美学参数经验编码成 itol-cli 的默认与护栏。
2
+
3
+ 文献锚(docs/METHODOLOGY.md):PLOS/Nature/Elsevier 期刊规格、Okabe-Ito/Paul Tol 色板、
4
+ Wilke 视觉通道、Phylo.io 可读性拐点。原则:字号随物理图幅缩放、定性色板分档、超阈值告警。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ # ── 色板(文献锚定)─────────────────────────────────────────────
9
+ # Okabe-Ito 色盲安全 8 色(定性色板上限之一,jfly.uni-koeln.de/color)
10
+ OKABE_ITO = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#000000"]
11
+ # Paul Tol muted 9 色(定性色板实际上限,sronpersonalpages.nl/~pault)
12
+ TOL_MUTED = ["#CC6677", "#332288", "#DDCC77", "#117733", "#88CCEE", "#882255", "#44AA99", "#999933", "#AA4499"]
13
+ # Paul Tol bright 7 色
14
+ TOL_BRIGHT = ["#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377", "#BBBBBB"]
15
+
16
+ # 定性色板分档阈值(Tol 官方:>9 类应换 banded sequential)
17
+ _PALETTE_TIERS = [(7, "tol_bright"), (9, "okabeito")] # ≤7 Tol-bright,8-9 Okabe-Ito/Tol-muted
18
+
19
+
20
+ def recommend_palette(n_groups: int) -> tuple[str, list[str], list[str]]:
21
+ """按分组数推荐定性色板(文献锚定分档)。返回 (色板名, 颜色列表, 警告列表)。
22
+
23
+ 分档(Tol/Okabe-Ito):≤7 → Tol-bright;8-9 → Okabe-Ito(或 Tol-muted);>9 → 告警类别过多。
24
+ """
25
+ warnings: list[str] = []
26
+ if n_groups <= 0:
27
+ return "tol_bright", TOL_BRIGHT, warnings
28
+ if n_groups <= 7:
29
+ return "tol_bright", TOL_BRIGHT[:n_groups], warnings
30
+ if n_groups <= 9:
31
+ warnings.append(f"{n_groups} 组接近定性色板上限(Okabe-Ito 8 色 / Tol muted 9 色)——考虑合并小组")
32
+ return "okabeito", (OKABE_ITO * 2)[:n_groups], warnings
33
+ warnings.append(
34
+ f"⚠️ {n_groups} 组超出定性色盲安全色板上限(Tol 官方:>9 类应改用 banded sequential 或合并组)。"
35
+ f"颜色将无法可靠区分——建议合并小组或改用连续/层级编码")
36
+ # 超上限:循环拼接 Tol muted + Okabe-Ito 兜底(用户仍可用,但被明确警告)
37
+ pool = TOL_MUTED + OKABE_ITO
38
+ return "tol_muted", [pool[i % len(pool)] for i in range(n_groups)], warnings
39
+
40
+
41
+ # ── 字号/可读性(字号随物理图幅缩放,pt 是绝对单位)──────────────────
42
+ # 期刊规格(实测官方页):PLOS 图内文字 8-12pt;Nature 90mm 版心下 5-7pt;Elsevier 7pt(下标≥6pt)
43
+ # 矩形树:可读标签数 ≈ 图幅高 / (字号对应行高)。1pt ≈ 0.35mm,行距≈1.2×字号
44
+
45
+ def max_readable_leaves(fig_height_mm: float, font_pt: float) -> int:
46
+ """给定图幅高+字号,估算矩形布局可读的最大叶数(行距≈1.2×字号)。"""
47
+ line_h_mm = font_pt * 0.3528 * 1.2
48
+ usable = fig_height_mm * 0.9 # 留边距
49
+ return max(1, int(usable / line_h_mm))
50
+
51
+
52
+ def readability_warnings(n_leaves: int, layout: str = "rect",
53
+ fig_width_mm: float = 180.0, fig_height_mm: float = 170.0) -> list[str]:
54
+ """可读性护栏:按叶数/布局/图幅给警告(文献锚:Phylo.io 可读性拐点 + Nature/PLOS 字号)。
55
+
56
+ 返回警告列表(空=可读)。layout: rect/circular/unrooted。
57
+ """
58
+ w: list[str] = []
59
+ if layout in ("rect", "rectangular"):
60
+ # 矩形布局:标签在右,行数=叶数;图高决定可读叶数
61
+ for pt in (8, 6): # 先试正文 8pt,再试下限 6pt
62
+ maxl = max_readable_leaves(fig_height_mm, pt)
63
+ if n_leaves <= maxl:
64
+ break
65
+ else:
66
+ w.append(f"⚠️ {n_leaves} 叶在 {fig_height_mm}mm 高矩形布局下,即使 6pt 字号也只能容 "
67
+ f"~{max_readable_leaves(fig_height_mm, 6)} 叶——标签将不可读。建议:改环形/折叠子树/拆面板")
68
+ if n_leaves > 50 and layout in ("rect", "rectangular"):
69
+ w.append(f"⚠️ {n_leaves} 叶超过并排/细读的可读性拐点(Phylo.io:几十 tip);建议环形布局或折叠单系群")
70
+ if layout == "circular" and n_leaves < 40:
71
+ w.append(f"💡 {n_leaves} 叶用矩形布局标签可读性更高(环形适合 >60 叶的紧凑展示)")
72
+ return w
73
+
74
+
75
+ def recommend_font_pt(n_leaves: int, fig_width_mm: float = 180.0) -> int:
76
+ """按叶数+图幅推荐叶标签字号(钳制在期刊合法区间 5-12pt)。"""
77
+ if n_leaves <= 40:
78
+ pt = 10
79
+ elif n_leaves <= 80:
80
+ pt = 8
81
+ elif n_leaves <= 150:
82
+ pt = 6
83
+ else:
84
+ pt = 5
85
+ return max(5, min(12, pt))
86
+
87
+
88
+ def channel_budget_check(layers: dict[str, str]) -> list[str]:
89
+ """视觉通道预算校验(Wilke Ch2:每通道最多绑 1 变量,单图 ≤5 scale)。
90
+
91
+ layers: {通道: 绑定的变量},如 {'color':'亚家族', 'size':'支持度'}。返回冲突警告。
92
+ """
93
+ w: list[str] = []
94
+ n = len([v for v in layers.values() if v])
95
+ if n > 5:
96
+ w.append(f"⚠️ 单图绑定 {n} 个视觉通道({layers})超过清晰上限 5(Wilke)——减少叠加层")
97
+ # 连续数据禁用 shape/linetype 通道
98
+ for ch in ("shape", "linetype"):
99
+ if layers.get(ch) and "连续" in str(layers[ch]):
100
+ w.append(f"⚠️ {ch} 通道只能编码离散数据(Wilke),{layers[ch]} 疑似连续——改用 size/color")
101
+ return w
102
+
103
+
104
+ # ── 迭代评分器(itol iterate 的目标函数)───────────────────────────────────────
105
+ def score_variant(*, n_leaves: int, n_groups: int, layout: str, palette: str,
106
+ font_pt: float, strip_width: int, n_datasets: int = 1,
107
+ fig_width_mm: float = 180.0, fig_height_mm: float = 170.0) -> tuple[float, list[str]]:
108
+ """对一个出图变体打综合分(0-10,越高越好)+ 评分明细。
109
+
110
+ 评分维度(文献锚,readability 权重高于 aesthetics——Rougier R9):
111
+ - 可读性(40%):叶数×布局×图幅,标签可读
112
+ - 色板适配(25%):定性色板分档匹配 + 色盲安全
113
+ - 信息密度(20%):通道预算(≤5 scale)
114
+ - 布局合理(15%):布局与叶数匹配
115
+ """
116
+ notes: list[str] = []
117
+ score = 10.0
118
+ # 可读性(权重 4)
119
+ rw = readability_warnings(n_leaves, layout, fig_width_mm, fig_height_mm)
120
+ read_pen = 4.0 if rw else 0.0
121
+ if rw:
122
+ notes.append(f"可读性 -{read_pen}({rw[0][:30]}…)")
123
+ score -= read_pen
124
+ # 色板适配(权重 2.5)
125
+ _, _, pwarn = recommend_palette(n_groups)
126
+ if any("超出" in w for w in pwarn):
127
+ score -= 2.5
128
+ notes.append("色板 -2.5(超定性色板上限)")
129
+ elif pwarn:
130
+ score -= 1.0
131
+ notes.append("色板 -1.0(接近上限)")
132
+ # 通道预算(权重 2)
133
+ if n_datasets > 5:
134
+ score -= 2.0
135
+ notes.append(f"密度 -2.0({n_datasets} 数据集 >5 scale 上限)")
136
+ elif n_datasets > 3:
137
+ score -= 1.0
138
+ notes.append(f"密度 -1.0({n_datasets} 数据集,接近上限)")
139
+ # 布局合理(权重 1.5)
140
+ if layout == "circular" and n_leaves < 40:
141
+ score -= 1.0
142
+ notes.append("布局 -1.0(小叶数环形不如矩形可读)")
143
+ if layout in ("rect", "rectangular") and n_leaves > 200:
144
+ score -= 1.5
145
+ notes.append("布局 -1.5(千叶级矩形标签糊)")
146
+ return max(0.0, round(score, 1)), notes
147
+
148
+
149
+ # 迭代变体搜索空间(美学参数轴)
150
+ SWEEP_AXES: dict[str, list] = {
151
+ "layout": ["rect", "circular"],
152
+ "palette": ["npg", "okabeito", "tol_bright", "tol_muted"],
153
+ "font_pt": [6, 8, 10],
154
+ "strip_width": [30, 50, 80],
155
+ }
156
+
157
+
158
+ # ── 色板评分(iterate 闭环的约束评分器;ColorBrewer「配色是约束求解」思想)─────────
159
+ NAMED_PALETTES = {
160
+ "npg": ["#E64B35", "#4DBBD5", "#00A087", "#3C5488", "#F39B7F", "#8491B4", "#91D1C2", "#DC0000", "#7E6148", "#B09C85"],
161
+ "okabeito": OKABE_ITO,
162
+ "tol_bright": TOL_BRIGHT,
163
+ "tol_muted": TOL_MUTED,
164
+ }
165
+
166
+
167
+ def _hex_rgb(h: str) -> tuple[int, int, int]:
168
+ h = h.lstrip("#")
169
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
170
+
171
+
172
+ def _srgb_linear(v: float) -> float:
173
+ v /= 255.0
174
+ return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
175
+
176
+
177
+ def _rgb_lab(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
178
+ """sRGB → CIELAB(D65)。返回 (L, a, b)。"""
179
+ r, g, b = (_srgb_linear(c) for c in rgb)
180
+ # linear RGB → XYZ (D65)
181
+ x = r * 0.4124 + g * 0.3576 + b * 0.1805
182
+ y = r * 0.2126 + g * 0.7152 + b * 0.0722
183
+ z = r * 0.0193 + g * 0.1192 + b * 0.9505
184
+ # 归一到 D65 白点
185
+ xn, yn, zn = x / 0.95047, y / 1.0, z / 1.08883
186
+
187
+ def f(t: float) -> float:
188
+ return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
189
+ fx, fy, fz = f(xn), f(yn), f(zn)
190
+ return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
191
+
192
+
193
+ def _sim_deuteranopia_lab(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
194
+ """Deuteranopia(绿色盲)模拟(linear RGB 矩阵)→ CIELAB。"""
195
+ r, g, b = (_srgb_linear(c) for c in rgb)
196
+ # Machado 2009 近似(linear RGB 域)
197
+ dr = 0.625 * r + 0.375 * g
198
+ dg = 0.7 * r + 0.3 * g
199
+ db = 0.3 * g + 0.7 * b
200
+ sim_rgb: tuple[int, int, int] = (round(dr * 255), round(dg * 255), round(db * 255))
201
+ return _rgb_lab(sim_rgb)
202
+
203
+
204
+ def _delta_e(lab1: tuple, lab2: tuple) -> float:
205
+ """CIE76 ΔE(CIELAB 欧氏距离,感知均匀)。"""
206
+ return sum((x - y) ** 2 for x, y in zip(lab1, lab2)) ** 0.5
207
+
208
+
209
+ def score_palette(colors: list[str], n_groups: int) -> dict:
210
+ """给一组色板打分(约束求解,非美学评判)。返回 {score, issues, min_sim_dist}。
211
+
212
+ 约束:①绿色盲模拟(CIELAB ΔE)两两可区分 ②组数不超色板安全上限 ③与白底对比度。
213
+ 分数 0-10。CIELAB ΔE:≥20 大致可区分,<12 难区分。
214
+ """
215
+ issues: list[str] = []
216
+ use = [colors[i % len(colors)] for i in range(n_groups)]
217
+ sim = [_sim_deuteranopia_lab(_hex_rgb(c)) for c in use]
218
+ mind = min((_delta_e(sim[i], sim[j]) for i in range(len(sim)) for j in range(i + 1, len(sim))), default=999)
219
+ cb_ok = mind >= 12.0 # CIELAB ΔE≥12 绿色盲下可区分(经验阈值)
220
+ if not cb_ok:
221
+ issues.append(f"绿色盲模拟下有两色难区分(min CIELAB ΔE={mind:.1f}<12)")
222
+ if n_groups > 9:
223
+ issues.append(f"{n_groups} 组超定性色板上限(Tol:>9 应换 banded sequential)")
224
+ def lum(c: str) -> float:
225
+ return _rgb_lab(_hex_rgb(c))[0]
226
+ lowcontrast = [c for c in use if lum(c) > 92] # CIELAB L* 太接近白(100)
227
+ if lowcontrast:
228
+ issues.append(f"{len(lowcontrast)} 色与白底对比不足(太浅:{lowcontrast[:2]})")
229
+ score = 10.0
230
+ if not cb_ok:
231
+ score -= 4
232
+ if n_groups > 9:
233
+ score -= 3
234
+ if lowcontrast:
235
+ score -= 1.5 * len(lowcontrast)
236
+ return {"score": max(0.0, round(score, 1)), "issues": issues, "min_sim_dist": round(mind, 1),
237
+ "colorblind_ok": cb_ok, "n_colors": n_groups}
238
+
239
+
240
+ def iterate_palettes(n_groups: int, palettes: dict[str, list[str]] | None = None) -> list[dict]:
241
+ """对全部命名色板打分并排序(iterate 的色板维度)。返回按分数降序的变体列表。"""
242
+ pals = palettes or NAMED_PALETTES
243
+ out = []
244
+ for name, colors in pals.items():
245
+ s = score_palette(colors, n_groups)
246
+ out.append({"palette": name, **s, "colors": [colors[i % len(colors)] for i in range(n_groups)]})
247
+ out.sort(key=lambda x: -x["score"])
248
+ return out