fix: atomic write, tag preservation, invalid preset error, additional tests
This commit is contained in:
parent
47faae74f4
commit
2677ccc42b
3 changed files with 50 additions and 18 deletions
5
conftest.py
Normal file
5
conftest.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# conftest.py
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "scripts"))
|
||||||
|
|
@ -8,7 +8,9 @@ Usage:
|
||||||
--preset cc|cs6
|
--preset cc|cs6
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
@ -24,14 +26,19 @@ PRESET_OVERRIDES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _find_or_create(parent: ET.Element, id_val: str) -> ET.Element:
|
def _atomic_write(tree: ET.ElementTree, path: Path) -> None:
|
||||||
"""Return child with matching id, or create and append a new one."""
|
"""Write XML tree atomically — prevents corruption on interrupt."""
|
||||||
for child in parent:
|
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
||||||
if child.get("id") == id_val:
|
try:
|
||||||
return child
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
child = ET.SubElement(parent, "group")
|
tree.write(fh, encoding="unicode", xml_declaration=True)
|
||||||
child.set("id", id_val)
|
os.replace(tmp, path)
|
||||||
return child
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _merge_nodes(live_parent: ET.Element, patch_parent: ET.Element) -> None:
|
def _merge_nodes(live_parent: ET.Element, patch_parent: ET.Element) -> None:
|
||||||
|
|
@ -40,24 +47,30 @@ def _merge_nodes(live_parent: ET.Element, patch_parent: ET.Element) -> None:
|
||||||
child_id = patch_child.get("id")
|
child_id = patch_child.get("id")
|
||||||
if child_id is None:
|
if child_id is None:
|
||||||
continue
|
continue
|
||||||
|
live_child = next(
|
||||||
live_child = _find_or_create(live_parent, child_id)
|
(c for c in live_parent if c.get("id") == child_id), None
|
||||||
|
)
|
||||||
# Merge attributes: only set attrs present in patch; preserve unknown attrs
|
if live_child is None:
|
||||||
|
live_child = ET.SubElement(live_parent, patch_child.tag)
|
||||||
|
live_child.set("id", child_id)
|
||||||
for attr, value in patch_child.attrib.items():
|
for attr, value in patch_child.attrib.items():
|
||||||
if attr != "id":
|
if attr != "id":
|
||||||
live_child.set(attr, value)
|
live_child.set(attr, value)
|
||||||
|
|
||||||
# Recurse into children
|
|
||||||
_merge_nodes(live_child, patch_child)
|
_merge_nodes(live_child, patch_child)
|
||||||
|
|
||||||
|
|
||||||
def _apply_preset(root: ET.Element, preset: str) -> None:
|
def _apply_preset(root: ET.Element, preset: str) -> None:
|
||||||
"""Apply preset-specific overrides (active keys file, document units)."""
|
"""Apply preset-specific overrides (active keys file, document units)."""
|
||||||
|
if preset not in PRESET_OVERRIDES:
|
||||||
|
raise ValueError(f"Unknown preset {preset!r}. Valid: {list(PRESET_OVERRIDES)}")
|
||||||
for id_path, attrs in PRESET_OVERRIDES[preset].items():
|
for id_path, attrs in PRESET_OVERRIDES[preset].items():
|
||||||
node = root
|
node = root
|
||||||
for id_val in id_path:
|
for id_val in id_path:
|
||||||
node = _find_or_create(node, id_val)
|
child = next((c for c in node if c.get("id") == id_val), None)
|
||||||
|
if child is None:
|
||||||
|
child = ET.SubElement(node, "group")
|
||||||
|
child.set("id", id_val)
|
||||||
|
node = child
|
||||||
for attr, value in attrs.items():
|
for attr, value in attrs.items():
|
||||||
node.set(attr, value)
|
node.set(attr, value)
|
||||||
|
|
||||||
|
|
@ -73,7 +86,7 @@ def merge(prefs_path: Path, patch_path: Path, preset: str) -> None:
|
||||||
shutil.copy(patch_path, prefs_path)
|
shutil.copy(patch_path, prefs_path)
|
||||||
live_tree = ET.parse(prefs_path)
|
live_tree = ET.parse(prefs_path)
|
||||||
_apply_preset(live_tree.getroot(), preset)
|
_apply_preset(live_tree.getroot(), preset)
|
||||||
live_tree.write(str(prefs_path), encoding="unicode", xml_declaration=True)
|
_atomic_write(live_tree, prefs_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
live_tree = ET.parse(prefs_path) # raises ParseError on malformed XML
|
live_tree = ET.parse(prefs_path) # raises ParseError on malformed XML
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
"""Tests for scripts/merge_prefs.py"""
|
"""Tests for scripts/merge_prefs.py"""
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
|
||||||
import merge_prefs
|
import merge_prefs
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures"
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
@ -138,3 +136,19 @@ def test_merge_raises_on_missing_patch(tmp_path):
|
||||||
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
|
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
|
||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
merge_prefs.merge(prefs, tmp_path / "nonexistent-patch.xml", "cc")
|
merge_prefs.merge(prefs, tmp_path / "nonexistent-patch.xml", "cc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_raises_on_malformed_patch(tmp_path):
|
||||||
|
prefs = tmp_path / "preferences.xml"
|
||||||
|
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
|
||||||
|
bad_patch = tmp_path / "bad-patch.xml"
|
||||||
|
bad_patch.write_text("not xml at all")
|
||||||
|
with pytest.raises(ET.ParseError):
|
||||||
|
merge_prefs.merge(prefs, bad_patch, "cc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_raises_on_invalid_preset(tmp_path):
|
||||||
|
prefs = tmp_path / "preferences.xml"
|
||||||
|
shutil.copy(FIXTURES / "prefs-fresh.xml", prefs)
|
||||||
|
with pytest.raises(ValueError, match="Unknown preset"):
|
||||||
|
merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "bad-preset")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue