feat: add merge_prefs.py with full test coverage

This commit is contained in:
pyr0ball 2026-05-25 19:17:13 -07:00
parent 612db50c2c
commit 47faae74f4
2 changed files with 242 additions and 0 deletions

102
scripts/merge_prefs.py Normal file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
merge_prefs.py Merge Illuscape config patch into Inkscape preferences.xml
Usage:
python3 merge_prefs.py --prefs ~/.config/inkscape/preferences.xml \
--patch config/preferences-patch.xml \
--preset cc|cs6
"""
import argparse
import shutil
from pathlib import Path
from xml.etree import ElementTree as ET
PRESET_OVERRIDES = {
"cc": {
("options", "keyboard"): {"file": "illustrator-cc"},
("options", "units"): {"doc": "px", "font": "px"},
},
"cs6": {
("options", "keyboard"): {"file": "illustrator-cs6"},
("options", "units"): {"doc": "pt", "font": "pt"},
},
}
def _find_or_create(parent: ET.Element, id_val: str) -> ET.Element:
"""Return child with matching id, or create and append a new one."""
for child in parent:
if child.get("id") == id_val:
return child
child = ET.SubElement(parent, "group")
child.set("id", id_val)
return child
def _merge_nodes(live_parent: ET.Element, patch_parent: ET.Element) -> None:
"""Recursively upsert patch_parent's children into live_parent."""
for patch_child in patch_parent:
child_id = patch_child.get("id")
if child_id is None:
continue
live_child = _find_or_create(live_parent, child_id)
# Merge attributes: only set attrs present in patch; preserve unknown attrs
for attr, value in patch_child.attrib.items():
if attr != "id":
live_child.set(attr, value)
# Recurse into children
_merge_nodes(live_child, patch_child)
def _apply_preset(root: ET.Element, preset: str) -> None:
"""Apply preset-specific overrides (active keys file, document units)."""
for id_path, attrs in PRESET_OVERRIDES[preset].items():
node = root
for id_val in id_path:
node = _find_or_create(node, id_val)
for attr, value in attrs.items():
node.set(attr, value)
def merge(prefs_path: Path, patch_path: Path, preset: str) -> None:
"""Merge patch into prefs, applying preset overrides. Lenient: creates prefs if missing."""
patch_tree = ET.parse(patch_path) # raises FileNotFoundError or ParseError
patch_root = patch_tree.getroot()
if not prefs_path.exists():
# Lenient: seed from patch, let Inkscape fill in the rest on first launch
prefs_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(patch_path, prefs_path)
live_tree = ET.parse(prefs_path)
_apply_preset(live_tree.getroot(), preset)
live_tree.write(str(prefs_path), encoding="unicode", xml_declaration=True)
return
live_tree = ET.parse(prefs_path) # raises ParseError on malformed XML
live_root = live_tree.getroot()
_merge_nodes(live_root, patch_root)
_apply_preset(live_root, preset)
live_tree.write(str(prefs_path), encoding="unicode", xml_declaration=True)
def main() -> None:
parser = argparse.ArgumentParser(
description="Merge Illuscape preferences patch into Inkscape preferences.xml"
)
parser.add_argument("--prefs", required=True, type=Path)
parser.add_argument("--patch", required=True, type=Path)
parser.add_argument("--preset", required=True, choices=["cc", "cs6"])
args = parser.parse_args()
merge(args.prefs, args.patch, args.preset)
print(f"Illuscape preferences merged ({args.preset} preset)")
if __name__ == "__main__":
main()

140
tests/test_merge_prefs.py Normal file
View file

@ -0,0 +1,140 @@
"""Tests for scripts/merge_prefs.py"""
import shutil
import sys
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
import merge_prefs
FIXTURES = Path(__file__).parent / "fixtures"
def _parse(path: Path) -> ET.Element:
return ET.parse(path).getroot()
def _attr(root: ET.Element, *id_path: str) -> dict:
"""Walk id_path through nested <group id=...> elements, return attribs."""
node = root
for id_val in id_path:
node = next(
(c for c in node if c.get("id") == id_val), None
)
assert node is not None, f"Node with id={id_val!r} not found"
return dict(node.attrib)
# ── merge into existing prefs ─────────────────────────────────────────────────
def test_merge_changes_patched_attributes(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
root = _parse(prefs)
assert _attr(root, "options", "scroll")["mousewheel-zoom"] == "false"
assert _attr(root, "options", "zoom")["mousewheel-zoom-ctrl"] == "true"
assert _attr(root, "tools", "nodes")["square-handles"] == "true"
def test_merge_creates_missing_nodes(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
root = _parse(prefs)
assert _attr(root, "options", "new-group")["new-attr"] == "new-value"
def test_merge_preserves_user_nodes(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-user-custom.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
root = _parse(prefs)
assert _attr(root, "options", "user-custom-setting")["my-value"] == "keep-this"
assert _attr(root, "tools", "user-tool-pref")["color"] == "#ff0000"
def test_merge_preserves_existing_unknown_attributes(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-user-custom.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
root = _parse(prefs)
assert _attr(root, "tools", "nodes").get("user-pref") == "preserved"
def test_merge_is_idempotent(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
content_after_first = prefs.read_text()
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
content_after_second = prefs.read_text()
assert content_after_first == content_after_second
# ── lenient: no prefs file ────────────────────────────────────────────────────
def test_merge_creates_prefs_when_missing(tmp_path):
prefs = tmp_path / "inkscape" / "preferences.xml"
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
assert prefs.exists()
root = _parse(prefs)
assert _attr(root, "options", "scroll")["mousewheel-zoom"] == "false"
def test_merge_creates_parent_dirs_when_missing(tmp_path):
prefs = tmp_path / "deep" / "nested" / "dir" / "preferences.xml"
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
assert prefs.exists()
# ── preset overrides ──────────────────────────────────────────────────────────
def test_preset_cc_sets_keys_file(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
root = _parse(prefs)
assert _attr(root, "options", "keyboard")["file"] == "illustrator-cc"
def test_preset_cs6_sets_keys_file(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cs6")
root = _parse(prefs)
assert _attr(root, "options", "keyboard")["file"] == "illustrator-cs6"
def test_preset_cc_sets_px_units(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
root = _parse(prefs)
assert _attr(root, "options", "units")["doc"] == "px"
def test_preset_cs6_sets_pt_units(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cs6")
root = _parse(prefs)
assert _attr(root, "options", "units")["doc"] == "pt"
# ── error handling ────────────────────────────────────────────────────────────
def test_merge_raises_on_malformed_prefs(tmp_path):
prefs = tmp_path / "preferences.xml"
prefs.write_text("this is not xml")
with pytest.raises(ET.ParseError):
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc")
def test_merge_raises_on_missing_patch(tmp_path):
prefs = tmp_path / "preferences.xml"
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
with pytest.raises(FileNotFoundError):
merge_prefs.merge(prefs, tmp_path / "nonexistent-patch.xml", "cc")