191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
fix_i18n_w1_dyn.py — dspy 动态串 i18n 重构(AST 安全)
|
||
把 `"text": EXPR`(EXPR 含拼接/格式化)重构为:
|
||
"otext": TEMPLATE, "text": TEMPLATE, "i18n": True, "i18n_params": {p0:..,p1:..}
|
||
TEMPLATE 用 ${p0}/${p1} 占位;i18n_params 提供 JSON 可序列化的值。
|
||
依赖框架 widget.js 的 i18n_params 支持(TextBase.set_attrs → i18n._(otext, params))。
|
||
|
||
只处理 ast 能解析的 Add 链 / Mod 格式化 / 纯常量(多行隐式拼接);
|
||
解析失败的记 manual,绝不破坏文件。
|
||
用法: python3 scripts/fix_i18n_w1_dyn.py /tmp/i18n.json [--dry-run]
|
||
"""
|
||
import json, re, sys, ast
|
||
from collections import defaultdict
|
||
|
||
PRINTF_SPEC = re.compile(r'%(?:\(\w+\))?[-+ #0]*\d*(?:\.\d+)?[hlL]?[diouxXeEfFgGcrsa%]')
|
||
|
||
|
||
def find_value_expr(raw, after_colon):
|
||
"""从 after_colon("text": 之后)深度扫描出值表达式 [start,end)。"""
|
||
i = after_colon
|
||
n = len(raw)
|
||
while i < n and raw[i] in ' \t\r\n':
|
||
i += 1
|
||
start = i
|
||
depth = 0
|
||
in_s = None
|
||
while i < n:
|
||
c = raw[i]
|
||
if in_s:
|
||
if c == '\\':
|
||
i += 2; continue
|
||
if c == in_s:
|
||
in_s = None
|
||
i += 1; continue
|
||
if c in '"\'':
|
||
in_s = c; i += 1; continue
|
||
if c in '([{':
|
||
depth += 1; i += 1; continue
|
||
if c in ')]}':
|
||
if depth == 0:
|
||
return start, i # 闭合外层结构 = 值结束
|
||
depth -= 1; i += 1; continue
|
||
if c == ',' and depth == 0:
|
||
return start, i
|
||
i += 1
|
||
return start, n
|
||
|
||
|
||
def src_of(node, expr_src):
|
||
try:
|
||
return ast.get_source_segment(expr_src, node)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def build_template_and_params(expr_src):
|
||
"""返回 (template_str, params_dict_src, ok, reason)。params_dict_src 是 Python 源码片段。"""
|
||
expr_src = expr_src.strip()
|
||
try:
|
||
node = ast.parse(expr_src, mode='eval').body
|
||
except Exception as e:
|
||
return None, None, False, f'ast解析失败:{e}'
|
||
|
||
# 纯常量字符串(含多行隐式拼接)→ 无参数
|
||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||
return node.value, None, True, 'const'
|
||
|
||
params = []
|
||
# Mod 格式化: "FMT" % args
|
||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod) \
|
||
and isinstance(node.left, ast.Constant) and isinstance(node.left.value, str):
|
||
fmt = node.left.value
|
||
right = node.right
|
||
if isinstance(right, ast.Tuple):
|
||
arg_srcs = [src_of(e, expr_src) for e in right.elts]
|
||
else:
|
||
arg_srcs = [src_of(right, expr_src)]
|
||
if any(a is None for a in arg_srcs):
|
||
return None, None, False, 'mod参数源提取失败'
|
||
# 走 fmt,把 spec 换 ${pN}
|
||
out = []
|
||
ai = 0
|
||
last = 0
|
||
for m in PRINTF_SPEC.finditer(fmt):
|
||
out.append(fmt[last:m.start()])
|
||
spec = m.group(0)
|
||
if spec == '%%':
|
||
out.append('%')
|
||
else:
|
||
pname = f'p{ai}'
|
||
out.append('${' + pname + '}')
|
||
if ai >= len(arg_srcs):
|
||
return None, None, False, 'mod参数数不足'
|
||
# 每个参数用自身 spec 重新格式化,保证显示一致 + JSON 可序列化
|
||
params.append((pname, f'str({spec!r} % ({arg_srcs[ai]},))'
|
||
if spec[-1] in 'diouxXeEfFgG' else
|
||
f'str({arg_srcs[ai]})'))
|
||
ai += 1
|
||
last = m.end()
|
||
out.append(fmt[last:])
|
||
template = ''.join(out)
|
||
if ai != len(arg_srcs):
|
||
return None, None, False, f'mod参数数不匹配 spec={ai} args={len(arg_srcs)}'
|
||
pd = '{' + ', '.join(f'"{k}": {v}' for k, v in params) + '}' if params else None
|
||
return template, pd, True, 'mod'
|
||
|
||
# Add 拼接链
|
||
parts = []
|
||
def flatten(n):
|
||
if isinstance(n, ast.BinOp) and isinstance(n.op, ast.Add):
|
||
flatten(n.left); flatten(n.right)
|
||
else:
|
||
parts.append(n)
|
||
flatten(node)
|
||
template_pieces = []
|
||
pi = 0
|
||
has_dyn = False
|
||
for p in parts:
|
||
if isinstance(p, ast.Constant) and isinstance(p.value, str):
|
||
template_pieces.append(p.value)
|
||
else:
|
||
s = src_of(p, expr_src)
|
||
if s is None:
|
||
return None, None, False, 'add操作数源提取失败'
|
||
pname = f'p{pi}'; pi += 1
|
||
template_pieces.append('${' + pname + '}')
|
||
params.append((pname, f'str({s})'))
|
||
has_dyn = True
|
||
if not has_dyn:
|
||
# 全是字符串常量拼接 → 合并
|
||
return ''.join(template_pieces), None, True, 'const-concat'
|
||
template = ''.join(template_pieces)
|
||
pd = '{' + ', '.join(f'"{k}": {v}' for k, v in params) + '}' if params else None
|
||
return template, pd, True, 'add'
|
||
|
||
|
||
def main():
|
||
report = json.load(open(sys.argv[1], encoding='utf-8'))
|
||
dry = '--dry-run' in sys.argv
|
||
viols = [v for v in report['violations']
|
||
if v['code'] == 'W1' and not v['git_ignored'] and v['file'].endswith('.dspy')]
|
||
by_file = defaultdict(list)
|
||
for v in viols:
|
||
by_file[v['file']].append(v)
|
||
|
||
fixed = manual = 0
|
||
manual_list = []
|
||
keys_added = []
|
||
for fp, vs in sorted(by_file.items()):
|
||
raw = open(fp, encoding='utf-8').read()
|
||
changed = False
|
||
for v in sorted(vs, key=lambda x: -x['pos']):
|
||
pos = v['pos']
|
||
mk = re.compile(r'''(['"])(text|otext)\1\s*:''').match(raw, pos)
|
||
if not mk:
|
||
manual += 1; manual_list.append((fp, pos, 'key定位失败', v['text'][:40])); continue
|
||
keyname = mk.group(2); q = mk.group(1)
|
||
vstart, vend = find_value_expr(raw, mk.end())
|
||
expr_src = raw[vstart:vend]
|
||
tmpl, pd, ok, why = build_template_and_params(expr_src)
|
||
if not ok:
|
||
manual += 1; manual_list.append((fp, pos, why, expr_src[:50])); continue
|
||
# 转义模板里的双引号/反斜杠进 JSON 字符串
|
||
tmpl_json = tmpl.replace('\\', '\\\\').replace('"', '\\"')
|
||
if keyname == 'otext' and pd is None and '不可退费' in tmpl:
|
||
# 仅缺 i18n:True
|
||
ins = f', {q}i18n{q}: True'
|
||
seg_new = raw[pos:vend] + ins
|
||
else:
|
||
params_part = f', {q}i18n_params{q}: {pd}' if pd else ''
|
||
seg_new = (f'{q}otext{q}: "{tmpl_json}", {q}text{q}: "{tmpl_json}", '
|
||
f'{q}i18n{q}: True{params_part}')
|
||
if not dry:
|
||
raw = raw[:pos] + seg_new + raw[vend:]
|
||
keys_added.append(tmpl)
|
||
fixed += 1; changed = True
|
||
if changed and not dry:
|
||
open(fp, 'w', encoding='utf-8').write(raw)
|
||
|
||
print(f'{"DRY-RUN " if dry else ""}fixed={fixed} manual={manual} files={len(by_file)}')
|
||
for mm in manual_list:
|
||
print(' MANUAL', mm[0].split('/repos/')[-1], f'@{mm[1]}', mm[2], '|', mm[3])
|
||
if dry:
|
||
print('--- 模板预览(将进字典的key) ---')
|
||
for k in keys_added:
|
||
print(' ', k[:70])
|
||
|
||
if __name__ == '__main__':
|
||
main()
|