mpltweak 0.1.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.
mpltweak/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ mpltweak —— 交互式 matplotlib 调图(脚本零侵入)
4
+
5
+ 产品形态:**库内核 + CLI 外挂入口**。
6
+ * 用户/agent 只接触 CLI:``mpltweak <script.py>`` 开窗调图,``mpltweak apply [--write]`` 写回;
7
+ * 本包是内核(toolbox / params / writeback),可被其它工具 import 嵌入;
8
+ * ``mpltweak.toolbox.gaitu(fig, ...)`` 是嵌入 API(Jupyter / 宿主进程内直接用),
9
+ 文档明确标注:走它 = 在脚本里加一行 import,会破坏"脚本零侵入",仅嵌入场景用。
10
+
11
+ 用户脚本里不出现本工具的任何痕迹;写回产出是纯 matplotlib 代码。
12
+ """
13
+
14
+ from .params import (
15
+ SCHEMA_VERSION,
16
+ defaults,
17
+ dump,
18
+ load,
19
+ normalize,
20
+ params_path,
21
+ validate,
22
+ )
23
+
24
+ __version__ = '0.1.0'
25
+ __all__ = [
26
+ 'SCHEMA_VERSION',
27
+ 'defaults',
28
+ 'dump',
29
+ 'load',
30
+ 'normalize',
31
+ 'params_path',
32
+ 'validate',
33
+ ]
mpltweak/apply.py ADDED
@@ -0,0 +1,291 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ mpltweak apply —— 「落实」助手(CLI:mpltweak apply <脚本.py>)
4
+ =====================================================================
5
+
6
+ 把 .tweak_params/*.json 变成改动清单 / 纯 matplotlib 片段,或**确定性写回脚本**:
7
+
8
+ mpltweak apply <脚本.py> # 改动清单(默认,只读不改)
9
+ mpltweak apply <脚本.py> --snippet # 额外输出可粘贴片段
10
+ mpltweak apply <脚本.py> --write # AST 确定性写回(见 mpltweak.writeback)
11
+ mpltweak apply <脚本.py> --write --no-verify # 写回但跳过 Agg 重跑验证
12
+ mpltweak apply <脚本.py> --params X # 指定参数文件
13
+
14
+ 它默认**不改脚本**(只读 + 只打印):落实交给 AI 按脚本自身风格完成;
15
+ 需要机械插入时用 ``--write``(AST 零 LLM,失败回退结构化提示)。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+
24
+ from . import params
25
+ from . import writeback
26
+
27
+
28
+ def _fmt(v):
29
+ return '%.4f' % v
30
+
31
+
32
+ def _num(v):
33
+ '''数值字面量:整数不带小数点,浮点保留 6 位有效数字。'''
34
+ try:
35
+ f = float(v)
36
+ except (TypeError, ValueError):
37
+ return repr(v)
38
+ if f == int(f) and abs(f) < 1e15:
39
+ return '%d' % int(f)
40
+ return '%.6g' % f
41
+
42
+
43
+ def _print_changes(script, data, p):
44
+ axes = data.get('axes', [])
45
+ print('参数文件: %s' % p)
46
+ print('脚本: %s' % os.path.basename(script))
47
+ print('画布: figsize_in=%s (px=%s)'
48
+ % (data.get('figsize_in'), data.get('figsize_px')))
49
+ print('-' * 62)
50
+ for a in axes:
51
+ if not isinstance(a, dict): # L1:参数文件里有脏轴项 → 不崩溃
52
+ print(' !! 参数文件里有一个非对象轴项,已跳过: %r' % (a,))
53
+ continue
54
+ i = a.get('index')
55
+ pos = a.get('pos')
56
+ print('ax%-2d 位置 [%s]' % (i, ', '.join(_fmt(v) for v in pos)))
57
+ if a.get('title_fontsize') is not None or a.get('label_fontsize') is not None:
58
+ print(' 字号 title=%s label=%s tick=%s'
59
+ % (a.get('title_fontsize'), a.get('label_fontsize'),
60
+ a.get('tick_fontsize')))
61
+ if a.get('clim') is not None:
62
+ print(' clim=%s' % a.get('clim'))
63
+ if a.get('cmap'):
64
+ print(' colormap=%s' % a.get('cmap'))
65
+ if a.get('grid') is not None:
66
+ print(' grid=%s spines=%s' % (a.get('grid'), a.get('spines')))
67
+ if a.get('xscale') or a.get('yscale'):
68
+ print(' scale x=%s y=%s' % (a.get('xscale'), a.get('yscale')))
69
+ for line in a.get('lines', []):
70
+ print(' line%d linewidth=%s color=%s'
71
+ % (line.get('index'), line.get('linewidth'), line.get('color')))
72
+ lg = a.get('legend')
73
+ if lg:
74
+ loc = lg.get('loc') or '(自由锚点)'
75
+ print(' 图例 loc=%s fontsize=%s' % (loc, lg.get('fontsize')))
76
+
77
+
78
+ def _print_snippet(data):
79
+ print('# --- 纯 matplotlib 落实片段(放在 tight_layout() 之后)---')
80
+ axes = data.get('axes', [])
81
+ print('for _ax, _p in zip(fig.axes, [')
82
+ for a in axes:
83
+ print(' [%s],' % ', '.join(_fmt(v) for v in a.get('pos')))
84
+ print(']):')
85
+ print(' _ax.set_position(_p)')
86
+ fs = data.get('figsize_in')
87
+ if fs:
88
+ print('# 画布: figsize=(%s, %s)' % (fs[0], fs[1]))
89
+ # 属性片段:clim / cmap / grid / spines / scale / 每根线
90
+ attr_blocks = []
91
+ for a in axes:
92
+ i = a.get('index')
93
+ rows = []
94
+ is_cb = a.get('is_colorbar')
95
+ if is_cb is None:
96
+ is_cb = a.get('clim') is not None # 旧参数文件兼容
97
+ if is_cb:
98
+ rows.append('# ax%d 是 colorbar 轴:先解除自动定位,否则重绘会重置位置/宽度' % i)
99
+ rows.append('fig.axes[%d].set_axes_locator(None)' % i)
100
+ rows.append('fig.axes[%d].set_box_aspect(None)' % i)
101
+ if a.get('clim') is not None:
102
+ rows.append('# clim:_mappable / _cb 换成你脚本里的变量名')
103
+ rows.append('# _mappable.set_clim(%s, %s)'
104
+ % (_num(a['clim'][0]), _num(a['clim'][1])))
105
+ rows.append('# _cb.update_normal(_mappable) # Colorbar 自己没有 set_clim')
106
+ if a.get('cmap'):
107
+ rows.append('# %s.set_cmap(%r)'
108
+ % ('_mappable' if is_cb else 'fig.axes[%d] 的 mappable' % i,
109
+ a['cmap']))
110
+ if a.get('grid') is not None:
111
+ rows.append('fig.axes[%d].grid(%s)' % (i, bool(a['grid'])))
112
+ for k, vis in (a.get('spines') or {}).items():
113
+ if not vis:
114
+ rows.append('fig.axes[%d].spines[%r].set_visible(False)' % (i, k))
115
+ if a.get('xscale'):
116
+ rows.append('fig.axes[%d].set_xscale(%r)' % (i, a['xscale']))
117
+ if a.get('yscale'):
118
+ rows.append('fig.axes[%d].set_yscale(%r)' % (i, a['yscale']))
119
+ for line in a.get('lines', []):
120
+ j = line.get('index')
121
+ if line.get('linewidth') is not None:
122
+ rows.append('fig.axes[%d].lines[%d].set_linewidth(%s)'
123
+ % (i, j, _num(line['linewidth'])))
124
+ if line.get('color'):
125
+ rows.append('fig.axes[%d].lines[%d].set_color(%r)' % (i, j, line['color']))
126
+ if rows:
127
+ attr_blocks.append((i, rows))
128
+ if attr_blocks:
129
+ print('# ---- 属性(#8):逐条按需粘贴 ----')
130
+ for i, rows in attr_blocks:
131
+ print('# ax%d' % i)
132
+ for r in rows:
133
+ print(r)
134
+
135
+
136
+ def _all_params_files(script, override=None):
137
+ """收集脚本的全部参数文件(主文件 + ``<stem>.fig<k>.json``),按图号排序。
138
+
139
+ 一次会话改多张图时每张各留一份(文件名带图号)→ 这里全收上来逐张落实。
140
+ 显式 ``--params`` 优先级最高:给了就只用它。
141
+ """
142
+ if override:
143
+ p = params.params_path(script, override)
144
+ return [p] if os.path.exists(p) else []
145
+ primary = params.params_path(script)
146
+ stem = os.path.splitext(primary)[0]
147
+ d, base = os.path.dirname(stem), os.path.basename(stem)
148
+ order = []
149
+ if os.path.isdir(d):
150
+ for fn in sorted(os.listdir(d)):
151
+ if (fn.startswith(base + '.fig') and fn.endswith('.json')
152
+ and not fn.endswith('.writeback.json')):
153
+ order.append((fn, os.path.join(d, fn)))
154
+ if os.path.exists(primary):
155
+ order.append((base + '.json', primary))
156
+
157
+ # 按 fig_index 去重:带图号的文件排在前面(`.fig0.json` < `.json`),
158
+ # 所以同一个图号优先用 <stem>.fig<k>.json(主文件只是它的副本)。
159
+ by_idx, fallback = {}, []
160
+ for _fn, pth in order:
161
+ try:
162
+ k = params.load(pth).get('fig_index')
163
+ except Exception: # noqa: BLE001
164
+ k = None
165
+ if isinstance(k, int):
166
+ by_idx.setdefault(k, pth)
167
+ else:
168
+ fallback.append(pth)
169
+ return [by_idx[k] for k in sorted(by_idx)] + fallback
170
+
171
+
172
+ def main(argv=None):
173
+ ap = argparse.ArgumentParser(prog='mpltweak apply')
174
+ ap.add_argument('script')
175
+ ap.add_argument('--params', default=None)
176
+ ap.add_argument('--snippet', action='store_true',
177
+ help='额外打印可直接粘贴的纯 matplotlib 片段')
178
+ ap.add_argument('--write', action='store_true',
179
+ help='AST 确定性写回脚本(零 LLM)')
180
+ ap.add_argument('--no-verify', action='store_true',
181
+ help='写回后跳过 Agg 无头重跑验证')
182
+ ap.add_argument('--no-semantic', action='store_true',
183
+ help='跳过语义验证(默认会重跑并比对目标图的位置/字号等是否真的等于参数)')
184
+ ap.add_argument('--all-figs', action='store_true',
185
+ help='循环出图时统一应用到所有迭代(默认只对参数记录的图号加 if 守卫)')
186
+ ap.add_argument('--style', default='inplace', choices=('inplace', 'block'),
187
+ help='写回方式:inplace=直接改原代码数字(默认,不加调整块);'
188
+ 'block=插调整块(旧方式,原位改不了时用)')
189
+ ap.add_argument('--python', default=None,
190
+ help='验证用解释器(默认当前进程的解释器)')
191
+ ap.add_argument('--timeout', type=float, default=300,
192
+ help='验证重跑超时秒数(默认 300)')
193
+ ap.add_argument('--dry-run', action='store_true',
194
+ help='只生成调整块预览,不落盘(调试用)')
195
+ args = ap.parse_args(argv)
196
+
197
+ script = os.path.abspath(args.script)
198
+ files = _all_params_files(script, args.params)
199
+ if not files:
200
+ print('no params: %s' % params.params_path(script, args.params))
201
+ return 2
202
+ if len(files) > 1:
203
+ print('发现 %d 份参数文件(一次会话改过多张图)——逐张落实:' % len(files))
204
+
205
+ results = []
206
+ for p in files:
207
+ try:
208
+ data = params.load(p)
209
+ except OSError as e:
210
+ print('!! 跳过 %s: %s' % (os.path.basename(p), e))
211
+ continue
212
+ k = data.get('fig_index')
213
+ print('=' * 62)
214
+ print('# %s%s' % (os.path.basename(p),
215
+ '' if not isinstance(k, int)
216
+ else ' (第 %d 张图)' % (k + 1)))
217
+ for prob in params.validate(data):
218
+ print('!! 参数文件结构警告: %s' % prob)
219
+
220
+ _print_changes(script, data, p)
221
+
222
+ if not args.write:
223
+ if args.snippet:
224
+ print('-' * 62)
225
+ _print_snippet(data)
226
+ continue
227
+
228
+ print('-' * 62)
229
+ res = writeback.writeback(script, data, p,
230
+ verify=not args.no_verify,
231
+ python=args.python,
232
+ timeout=args.timeout,
233
+ dry_run=args.dry_run,
234
+ semantic=not args.no_semantic,
235
+ only_fig=not args.all_figs,
236
+ style=args.style)
237
+ results.append((p, res))
238
+ if res['reason'] in ('ok', 'best_effort'):
239
+ if res.get('style') == 'inplace':
240
+ ch = res.get('changes') or []
241
+ print('✓ 已原位写回: %s' % script)
242
+ print(' 原位修改 %d 处: %s' % (len(ch), ', '.join(ch)[:160]))
243
+ else:
244
+ print('✓ 已写回: %s' % script)
245
+ print(' fig 变量名 = %s 插入点 = %s figsize 原位替换 = %s'
246
+ % (res.get('fig_var'), res.get('anchor'),
247
+ res.get('figsize_edited')))
248
+ if res.get('backup'):
249
+ print(' 备份: %s' % res['backup'])
250
+ if res.get('verified') is True:
251
+ print(' ✓ Agg 重跑验证通过')
252
+ elif res.get('verified') is False:
253
+ print(' ✗ Agg 重跑验证失败(已回滚到备份)')
254
+ if res.get('semantic') is True:
255
+ print(' ✓ 语义验证通过(目标图状态 == 参数)')
256
+ elif res.get('semantic') is None and not args.no_semantic:
257
+ print(' · 语义验证未能判定(脚本不存图/跑不通),仅按"能跑通"判定')
258
+ for w in res.get('warnings', []):
259
+ print(' · 保持原样: %s' % w)
260
+ if args.dry_run:
261
+ print(' [dry-run] 未落盘;%s:' % ('原位修改预览'
262
+ if res.get('style') == 'inplace' else '生成的调整块'))
263
+ print(res['block'])
264
+ elif res['reason'] == 'no_change':
265
+ print('· 参数与原代码一致,无需改动')
266
+ for w in res.get('warnings', []):
267
+ print(' · 保持原样: %s' % w)
268
+ elif res['reason'] == 'no_fig':
269
+ print('✗ 无法确定性写回: %s' % res['err'])
270
+ print(' 建议:由 AI 按脚本风格落实,或 --snippet 拿片段手动粘贴')
271
+ else:
272
+ print('✗ 写回失败[%s]: %s' % (res['reason'], res['err']))
273
+
274
+ if not args.write:
275
+ print('-' * 62)
276
+ print('提示:以上数值需按脚本自身风格写回;colorbar 轴若改了位置,必须同时')
277
+ print(' cb.ax.set_box_aspect(None),否则 matplotlib 每次重绘会把宽度重置回自动值。')
278
+ print(' 机械插入可用 --write(AST 确定性写回,零 LLM)。')
279
+ return 0
280
+
281
+ bad = [r for _, r in results
282
+ if r['reason'] not in ('ok', 'best_effort', 'no_change')]
283
+ if len(results) > 1:
284
+ print('=' * 62)
285
+ print('多图落实小结:共 %d 张,成功 %d,失败 %d'
286
+ % (len(results), len(results) - len(bad), len(bad)))
287
+ return 1 if bad else 0
288
+
289
+
290
+ if __name__ == '__main__':
291
+ sys.exit(main())
mpltweak/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ mpltweak —— CLI 入口(库内核 + CLI 外挂)
4
+
5
+ 主推用法(脚本零侵入,脚本里不出现本工具任何痕迹):
6
+
7
+ mpltweak <script.py> [--fig N] [--params X] # 跑脚本 + 弹交互调图窗,关窗只存参数
8
+ mpltweak apply <script.py> # 改动清单(只读)
9
+ mpltweak apply <script.py> --write # AST 确定性写回(零 LLM)
10
+ mpltweak apply <script.py> --snippet # 输出可粘贴片段
11
+ mpltweak doctor # 环境自检(后端/绑定/版本)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+
18
+
19
+ def _usage(out):
20
+ out.write(
21
+ 'mpltweak —— 像在 PPT 里调多图排版一样调 matplotlib(脚本零侵入)\n'
22
+ '\n'
23
+ '用法:\n'
24
+ ' mpltweak <script.py> [--fig N] [--params X] [--dry-run] 开窗调图\n'
25
+ ' mpltweak apply <script.py> [--write] [--snippet] [--params X]\n'
26
+ ' mpltweak doctor 环境自检\n'
27
+ '\n'
28
+ '交互:拖面板移动 / PPT式缩放 / Ctrl+点击多选 / 对齐均分 / 吸附 /\n'
29
+ ' Ctrl+F 裁白边 / 悬停±字号 / 方向键微调 / 空格线条模式 /\n'
30
+ ' Ctrl+Z 撤销,Ctrl+Y 或 Ctrl+Shift+Z 重做\n'
31
+ )
32
+
33
+
34
+ def main(argv=None):
35
+ argv = list(sys.argv[1:] if argv is None else argv)
36
+ if not argv:
37
+ _usage(sys.stderr)
38
+ return 2
39
+ cmd = argv[0]
40
+ if cmd in ('-h', '--help', 'help'):
41
+ _usage(sys.stdout)
42
+ return 0
43
+ if cmd == 'apply':
44
+ from .apply import main as apply_main
45
+ return apply_main(argv[1:])
46
+ if cmd == 'doctor':
47
+ from .launch import doctor
48
+ return doctor()
49
+ # 默认:launch(透传剩余参数;脚本自己的参数由 launch 的 parse_known_args 接住)
50
+ from .launch import main as launch_main
51
+ return launch_main(argv)
52
+
53
+
54
+ if __name__ == '__main__':
55
+ sys.exit(main())