pipeline-app/scripts/fix_i18n_w1_dyn.py

191 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()