00774ea52b
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>
106 lines
3.6 KiB
Python
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()
|