#!/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: # 2 空格 + "- " status: planned # 4 空格 items: - 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