225 lines
7.9 KiB
Python
225 lines
7.9 KiB
Python
"""一次性脚本:将外置模块中的 `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) 相对于 snippet(1-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()
|