Files
pqAutomationApp/tools/refactor_to_mixins.py

225 lines
7.9 KiB
Python
Raw Normal View History

"""一次性脚本:将外置模块中的 `def f(self, ...)` 自由函数转换为 Mixin 方式。
操作
1. 给所有顶层 `def f(self, ...)` `self: "PQAutomationApp"` 注解仅注解不移动
2. 在文件顶部首个 def/class 之前import 块之后插入 TYPE_CHECKING
3. 在文件末尾追加 `class XxxMixin:` 把这些函数作为类属性挂上
不会改变
- 函数体包括内部 `_xxx(self, ...)` 直接调用
- 已存在的类模块级常量
"""
from __future__ import annotations
import ast
import io
import os
import re
import sys
import tokenize
from dataclasses import dataclass
# 文件 -> Mixin 类名
TARGETS: dict[str, str] = {
"app/config_io.py": "ConfigIOMixin",
"app/views/chart_frame.py": "ChartFrameMixin",
"app/views/panels/main_layout.py": "MainLayoutMixin",
"app/views/panels/cct_panel.py": "CctPanelMixin",
"app/device/connection.py": "DeviceConnectionMixin",
"app/views/panels/custom_template_panel.py": "CustomTemplatePanelMixin",
"app/views/panels/side_panels.py": "SidePanelsMixin",
"app/views/panels/ai_image_panel.py": "AIImagePanelMixin",
"app/views/panels/single_step_panel.py": "SingleStepPanelMixin",
"app/views/panels/pantone_baseline_panel.py": "PantoneBaselinePanelMixin",
"app/tests/local_dimming.py": "LocalDimmingMixin",
"app/views/panel_manager.py": "PanelManagerMixin",
"app/runner/test_runner.py": "TestRunnerMixin",
"app/plots/plot_gamut.py": "PlotGamutMixin",
"app/plots/plot_gamma.py": "PlotGammaMixin",
"app/plots/plot_eotf.py": "PlotEotfMixin",
"app/plots/plot_cct.py": "PlotCctMixin",
"app/plots/plot_contrast.py": "PlotContrastMixin",
"app/plots/plot_accuracy.py": "PlotAccuracyMixin",
}
TYPE_CHECKING_BLOCK = (
"from typing import TYPE_CHECKING\n"
"\n"
"if TYPE_CHECKING:\n"
" from pqAutomationApp import PQAutomationApp\n"
"\n"
)
@dataclass
class SelfFunc:
name: str
lineno: int # 1-based, line of `def`
col_offset: int
end_lineno: int
def_line_idx: int # 0-based line index of `def ...` line
self_token_line_idx: int # 0-based line index of `self`
self_token_col: int
def _read(path: str) -> str:
with open(path, "rb") as f:
data = f.read()
# 去 BOM
if data.startswith(b"\xef\xbb\xbf"):
data = data[3:]
return data.decode("utf-8")
def _write(path: str, text: str) -> None:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def _locate_self_token(src_lines: list[str], def_line_idx: int) -> tuple[int, int] | None:
"""在 def 行(可能多行签名)中定位首个参数 `self` 的位置。"""
# 用 tokenize 解析从 def 行开始的片段
snippet = "".join(src_lines[def_line_idx:])
try:
tokens = list(tokenize.generate_tokens(io.StringIO(snippet).readline))
except tokenize.TokenizeError:
return None
saw_open_paren = False
for tok in tokens:
if tok.type == tokenize.OP and tok.string == "(":
saw_open_paren = True
continue
if saw_open_paren and tok.type == tokenize.NAME and tok.string == "self":
# tok.start 是 (row, col) 相对于 snippet1-based row
row_in_snippet = tok.start[0] - 1
col = tok.start[1]
return (def_line_idx + row_in_snippet, col)
if saw_open_paren and tok.type == tokenize.OP and tok.string in (")", ","):
# 第一个参数不是 self —— 跳过
return None
return None
def collect_self_funcs(src: str) -> tuple[ast.Module, list[SelfFunc]]:
tree = ast.parse(src)
src_lines = src.splitlines(keepends=True)
results: list[SelfFunc] = []
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.args.args and node.args.args[0].arg == "self":
def_idx = node.lineno - 1
pos = _locate_self_token(src_lines, def_idx)
if pos is None:
continue
results.append(SelfFunc(
name=node.name,
lineno=node.lineno,
col_offset=node.col_offset,
end_lineno=node.end_lineno or node.lineno,
def_line_idx=def_idx,
self_token_line_idx=pos[0],
self_token_col=pos[1],
))
return tree, results
def annotate_self(src: str, funcs: list[SelfFunc]) -> str:
"""把每个 def 的首个 `self` 形参替换为 `self: "PQAutomationApp"`。"""
lines = src.splitlines(keepends=True)
# 从后往前替换,避免行号变动
for fn in sorted(funcs, key=lambda f: -f.self_token_line_idx):
line = lines[fn.self_token_line_idx]
col = fn.self_token_col
# 检查后续是否已经有注解
after = line[col + len("self"):]
# 已经注解过则跳过
m = re.match(r"\s*:", after)
if m:
continue
new_line = line[:col] + 'self: "PQAutomationApp"' + line[col + len("self"):]
lines[fn.self_token_line_idx] = new_line
return "".join(lines)
def ensure_type_checking_block(src: str) -> str:
if "from pqAutomationApp import PQAutomationApp" in src:
return src
# 找到首个非 docstring / 非注释 / 非 import 的位置:
# 简单策略:在最后一个 import 行之后插入;若没有 import则在 docstring 之后插入。
lines = src.splitlines(keepends=True)
insert_idx = 0
in_docstring = False
doc_quote: str | None = None
last_import_idx = -1
for i, line in enumerate(lines):
stripped = line.lstrip()
if i == 0 and (stripped.startswith('"""') or stripped.startswith("'''")):
q = stripped[:3]
doc_quote = q
if stripped.count(q) >= 2 and len(stripped) > 3:
# 单行 docstring
last_import_idx = max(last_import_idx, i)
continue
in_docstring = True
continue
if in_docstring:
if doc_quote and doc_quote in line:
in_docstring = False
last_import_idx = max(last_import_idx, i)
continue
if stripped.startswith("import ") or stripped.startswith("from "):
last_import_idx = i
continue
if stripped == "" or stripped.startswith("#"):
continue
# 遇到第一个真实代码
break
insert_idx = last_import_idx + 1
new_lines = lines[:insert_idx] + ["\n", TYPE_CHECKING_BLOCK] + lines[insert_idx:]
return "".join(new_lines)
def append_mixin(src: str, mixin_name: str, func_names: list[str]) -> str:
if f"class {mixin_name}" in src:
return src
body_lines = []
body_lines.append("")
body_lines.append("")
body_lines.append(f"class {mixin_name}:")
body_lines.append(f' """由 tools/refactor_to_mixins.py 自动生成。')
body_lines.append(" 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。")
body_lines.append(' """')
for name in func_names:
body_lines.append(f" {name} = {name}")
text = "\n".join(body_lines) + "\n"
if not src.endswith("\n"):
src += "\n"
return src + text
def process(path: str, mixin_name: str) -> None:
src = _read(path)
tree, funcs = collect_self_funcs(src)
if not funcs:
print(f" -> skip (no self-funcs)")
return
func_names = [f.name for f in funcs]
new_src = annotate_self(src, funcs)
new_src = ensure_type_checking_block(new_src)
new_src = append_mixin(new_src, mixin_name, func_names)
_write(path, new_src)
print(f" -> {mixin_name} with {len(func_names)} methods")
def main():
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(root)
for rel, mixin in TARGETS.items():
print(f"Processing {rel}")
process(rel, mixin)
if __name__ == "__main__":
main()