Files
tianxuan/scripts/merge_config.py
PM-pinou 00774ea52b feat(scripts): add incremental update system with version tracking and diff utilities
Add complete incremental update infrastructure:
- VERSION: project version tracking file
- scripts/version_utils.py: shared version parsing and comparison utilities
- scripts/build_update.py: build incremental update package from git diff
- scripts/apply_update.py: apply offline incremental update
- scripts/merge_config.py: safe config merge logic for update safety
- scripts/migrate_db_to_appdata.py: migrate DB from project dir to APPDATA

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-20 14:14:12 +08:00

106 lines
3.6 KiB
Python

"""
Config merge utility for incremental updates.
Preserves user edits in config.yaml while adding new configuration keys
introduced by source code updates.
Usage:
python scripts/merge_config.py --old <user_config.yaml> --new <source_config.yaml> --output <merged.yaml>
"""
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError:
# Fallback when running outside Django context
try:
import ruamel.yaml as yaml
except ImportError:
print("[ERROR] PyYAML is required. Install with: pip install pyyaml", file=sys.stderr)
sys.exit(1)
def deep_merge(base: dict, overlay: dict) -> dict:
"""Merge overlay into base, preserving base values for existing keys.
Rules:
- Keys in base that are NOT in overlay: preserved unchanged
- Keys in overlay that are NOT in base: added from overlay
- Keys in BOTH: base value wins (user edits preserved)
- Nested dicts: recursively merged with same rules
- Lists/scalars: base wins entirely (no element-level merge)
"""
result = dict(base)
for k, v in overlay.items():
if k not in result:
# New key from update → take new value
result[k] = v
elif isinstance(v, dict) and isinstance(result.get(k), dict):
# Both are dicts → recursive merge
result[k] = deep_merge(result[k], v)
# else: key exists in both → base wins (user edit preserved)
return result
def merge_config(old_path: Path, new_path: Path, output_path: Path) -> int:
"""Merge old (user) config with new (source) config.
Returns:
0 on success, 1 on error
"""
if not old_path.exists():
print(f"[CONFIG] No user config at {old_path}, using source config directly")
import shutil
shutil.copy2(str(new_path), str(output_path))
return 0
if not new_path.exists():
print(f"[ERROR] Source config {new_path} not found", file=sys.stderr)
return 1
try:
with open(old_path, 'r', encoding='utf-8') as f:
old = yaml.safe_load(f) or {}
except Exception as e:
print(f"[CONFIG_MERGE_FAILED] Cannot parse user config: {e}", file=sys.stderr)
print("[CONFIG] Falling back to source config")
import shutil
shutil.copy2(str(new_path), str(output_path))
return 0
try:
with open(new_path, 'r', encoding='utf-8') as f:
new = yaml.safe_load(f) or {}
except Exception as e:
print(f"[ERROR] Cannot parse source config: {e}", file=sys.stderr)
return 1
merged = deep_merge(old, new)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
yaml.dump(merged, f, default_flow_style=False, allow_unicode=True)
# Count preserved vs new keys
preserved = sum(1 for k in merged if k in old)
added = sum(1 for k in merged if k not in old)
print(f"[CONFIG] Merged: {preserved} keys preserved, {added} keys added from update")
return 0
def main():
parser = argparse.ArgumentParser(description='Merge user config with source config for incremental updates')
parser.add_argument('--old', required=True, help='Path to user config (existing, may have edits)')
parser.add_argument('--new', required=True, help='Path to source config (from update package)')
parser.add_argument('--output', required=True, help='Path to write merged config')
args = parser.parse_args()
sys.exit(merge_config(
Path(args.old), Path(args.new), Path(args.output)
))
if __name__ == '__main__':
main()