登记 chinese-poetry / ck12 / phet,并让 source schema、lint 与 INDEX 能按学科和抓取进度消费。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
152
tools/yaml_patch.py
普通文件
152
tools/yaml_patch.py
普通文件
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""yaml_patch.py — 对 sources/*.yaml 做**行级**字段回填,保留注释与原有格式。
|
||||
|
||||
为什么需要它:
|
||||
`yaml.safe_load` + `yaml.safe_dump` 会把整个文件重写,**注释全部丢失**。
|
||||
sources/*.yaml 里的注释承载了关键信息(下载模板、许可解释、未纳入原因、运维提示),
|
||||
必须保住。因此所有自动回填(fetch_source.py / oss_sync.py)统一走本模块的行级替换。
|
||||
|
||||
约定的缩进层级(与 sources/*.yaml 一致):
|
||||
sources:
|
||||
- id: <source_id> # 2 空格 + "- "
|
||||
status: planned # 4 空格
|
||||
items:
|
||||
- id: <item_id> # 6 空格 + "- "
|
||||
oss_key: ... # 8 空格
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
def _yaml_scalar(v):
|
||||
"""把 Python 值渲染成 YAML 标量。"""
|
||||
if v is None:
|
||||
return "null"
|
||||
if isinstance(v, bool):
|
||||
return "true" if v else "false"
|
||||
if isinstance(v, (int, float)):
|
||||
return str(v)
|
||||
s = str(v)
|
||||
# 需要引号的情况:空串、含特殊起始字符、含冒号空格、纯数字样式
|
||||
if s == "" or s[0] in "&*?|-<>=!%@`{[\"'" or ": " in s or s.endswith(":"):
|
||||
return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
return s
|
||||
|
||||
|
||||
def patch_item_fields(path, source_id, item_id, fields):
|
||||
"""回填某 source 下某 item 的字段。返回 True 表示有改动。
|
||||
|
||||
fields: dict,如 {"sha256": "ab..", "size": 123, "status": "fetched"}
|
||||
已存在的键 → 原地替换;不存在的键 → 追加到该 item 块末尾。
|
||||
"""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
lines = f.read().split("\n")
|
||||
|
||||
src_re = re.compile(r"^ - id: (\S+)\s*$")
|
||||
item_re = re.compile(r"^ - id: (\S+)\s*$")
|
||||
|
||||
# 1) 定位 source 块范围
|
||||
s_start = s_end = None
|
||||
for i, ln in enumerate(lines):
|
||||
m = src_re.match(ln)
|
||||
if m:
|
||||
if m.group(1) == source_id and s_start is None:
|
||||
s_start = i
|
||||
elif s_start is not None and s_end is None:
|
||||
s_end = i
|
||||
break
|
||||
if s_start is None:
|
||||
return False
|
||||
if s_end is None:
|
||||
s_end = len(lines)
|
||||
|
||||
# 2) 在 source 块内定位 item 块范围
|
||||
i_start = i_end = None
|
||||
for i in range(s_start, s_end):
|
||||
m = item_re.match(lines[i])
|
||||
if m:
|
||||
if m.group(1) == item_id and i_start is None:
|
||||
i_start = i
|
||||
elif i_start is not None and i_end is None:
|
||||
i_end = i
|
||||
break
|
||||
if i_start is None:
|
||||
return False
|
||||
if i_end is None:
|
||||
# item 块延伸到 source 块末尾;剔除尾部空行与注释行
|
||||
j = s_end - 1
|
||||
while j > i_start and (lines[j].strip() == "" or lines[j].lstrip().startswith("#")):
|
||||
j -= 1
|
||||
i_end = j + 1
|
||||
|
||||
# 3) 逐字段替换或追加
|
||||
changed = False
|
||||
for key, val in fields.items():
|
||||
new_line = f" {key}: {_yaml_scalar(val)}"
|
||||
hit = None
|
||||
for i in range(i_start, i_end):
|
||||
if re.match(rf"^ {re.escape(key)}:", lines[i]):
|
||||
hit = i
|
||||
break
|
||||
if hit is not None:
|
||||
if lines[hit] != new_line:
|
||||
lines[hit] = new_line
|
||||
changed = True
|
||||
else:
|
||||
lines.insert(i_end, new_line)
|
||||
i_end += 1
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
return changed
|
||||
|
||||
|
||||
def patch_source_fields(path, source_id, fields):
|
||||
"""回填 source 级字段(如 status / fetched_at)。返回 True 表示有改动。"""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
lines = f.read().split("\n")
|
||||
|
||||
src_re = re.compile(r"^ - id: (\S+)\s*$")
|
||||
s_start = s_end = None
|
||||
for i, ln in enumerate(lines):
|
||||
m = src_re.match(ln)
|
||||
if m:
|
||||
if m.group(1) == source_id and s_start is None:
|
||||
s_start = i
|
||||
elif s_start is not None and s_end is None:
|
||||
s_end = i
|
||||
break
|
||||
if s_start is None:
|
||||
return False
|
||||
if s_end is None:
|
||||
s_end = len(lines)
|
||||
|
||||
# items: 之前才是 source 级字段区
|
||||
limit = s_end
|
||||
for i in range(s_start, s_end):
|
||||
if re.match(r"^ items:", lines[i]):
|
||||
limit = i
|
||||
break
|
||||
|
||||
changed = False
|
||||
for key, val in fields.items():
|
||||
new_line = f" {key}: {_yaml_scalar(val)}"
|
||||
hit = None
|
||||
for i in range(s_start, limit):
|
||||
if re.match(rf"^ {re.escape(key)}:", lines[i]):
|
||||
hit = i
|
||||
break
|
||||
if hit is not None:
|
||||
if lines[hit] != new_line:
|
||||
lines[hit] = new_line
|
||||
changed = True
|
||||
else:
|
||||
lines.insert(limit, new_line)
|
||||
limit += 1
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
return changed
|
||||
在新工单中引用
屏蔽一个用户