feat: add merge_prefs.py with full test coverage
This commit is contained in:
parent
612db50c2c
commit
47faae74f4
2 changed files with 242 additions and 0 deletions
102
scripts/merge_prefs.py
Normal file
102
scripts/merge_prefs.py
Normal 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
140
tests/test_merge_prefs.py
Normal 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")
|
||||||
Loading…
Reference in a new issue