Files
SaraSuite/ConfigMerge.py
T
2026-09-24 22:04:04 +02:00

127 lines
4.9 KiB
Python

"""Smart config updating: a three-way merge between
Base - the default config as it was last installed (kept in Pristine/),
Ours - the user's config as it is on disk now,
Theirs - the new default config from the updated program.
Per key: the user's edits always win; defaults the user never touched follow
the new version; new keys are added; keys dropped from the new default are
removed only if the user never changed them. TOML is merged key by key with
tomlkit, so the user's comments and layout survive. Anything else that both
sides changed is left alone and the new default is written beside it as .new.
"""
import copy
from dataclasses import dataclass, field
from typing import Any, Optional
import tomlkit
from tomlkit.container import Container
_Missing = object()
@dataclass
class MergeResult:
# "Place": no config yet, the default is written.
# "Keep": nothing new in the default, the user's file is untouched.
# "Replace": the user never edited it, so it becomes the new default.
# "Merge": both changed, merged key by key (Changes lists what moved).
# "KeepAndNew": both changed and can't be merged; the new default goes to .new.
Action: str
Text: Optional[str] = None # what to write to the target (None = leave it)
Changes: list[str] = field(default_factory=list)
def Describe(self) -> str:
Summary = {
"Place": "new, will be placed",
"Keep": "no changes in the new version, yours is kept as-is",
"Replace": "you haven't edited it, updated to the new default",
"Merge": "merged with your edits",
"KeepAndNew": "you and the new version both changed it; yours is kept, new default saved as .new",
}[self.Action]
if self.Action == "Merge" and not self.Changes:
return "your edits already cover everything new, kept as-is"
return Summary
def _Plain(Value: Any) -> Any:
return Value.unwrap() if hasattr(Value, "unwrap") else Value
def _MergeTables(Base: dict, Ours: Container, Theirs: Container, Prefix: str, Changes: list[str]) -> None:
for Key in list(Theirs.keys()):
KeyPath = f"{Prefix}{Key}"
TheirsItem = Theirs[Key]
TheirsValue = _Plain(TheirsItem)
BaseValue = Base.get(Key, _Missing)
if Key not in Ours:
if BaseValue is not _Missing:
continue # was in the old default and the user deleted it: respect that
Ours[Key] = copy.deepcopy(TheirsItem)
Changes.append(f"added {KeyPath}")
continue
OursItem = Ours[Key]
OursValue = _Plain(OursItem)
if isinstance(TheirsValue, dict) and isinstance(OursValue, dict):
_MergeTables(BaseValue if isinstance(BaseValue, dict) else {}, OursItem, TheirsItem, f"{KeyPath}.", Changes)
continue
if OursValue == TheirsValue:
continue
if BaseValue is not _Missing and OursValue == BaseValue:
Ours[Key] = copy.deepcopy(TheirsItem)
Changes.append(f"updated {KeyPath}")
# Otherwise the user changed it (or there's no base to tell): keep the user's value.
for Key in list(Ours.keys()):
if Key in Theirs or Key not in Base:
continue
if _Plain(Ours[Key]) == Base[Key]:
del Ours[Key]
Changes.append(f"removed {Prefix}{Key}")
def MergeConfig(Base: Optional[str], Ours: Optional[str], Theirs: str, IsToml: bool) -> MergeResult:
"""Base is None when there's no record of the old default (first install
over an existing config, or the Pristine copy went missing): every key
already in the user's file is then treated as the user's choice."""
if Ours is None:
return MergeResult("Place", Theirs)
if Ours == Theirs or Base == Theirs:
return MergeResult("Keep")
if Ours == Base:
return MergeResult("Replace", Theirs)
if not IsToml:
return MergeResult("KeepAndNew")
try:
OursDoc = tomlkit.parse(Ours)
TheirsDoc = tomlkit.parse(Theirs)
BaseData = tomlkit.parse(Base).unwrap() if Base is not None else {}
except tomlkit.exceptions.TOMLKitError:
return MergeResult("KeepAndNew")
Changes: list[str] = []
_MergeTables(BaseData, OursDoc, TheirsDoc, "", Changes)
if not Changes:
return MergeResult("Merge")
return MergeResult("Merge", tomlkit.dumps(OursDoc), Changes)
def ApplySet(Text: str, Values: dict[str, str]) -> str:
"""Set dotted TOML keys (e.g. "DirectoryPaths.RootDir") in a config's text,
used to fill install-time answers into a default config."""
if not Values:
return Text
Doc = tomlkit.parse(Text)
for DottedKey, Value in Values.items():
*Tables, LastKey = DottedKey.split(".")
Table = Doc
for TableName in Tables:
if TableName not in Table:
Table[TableName] = tomlkit.table()
Table = Table[TableName]
Table[LastKey] = Value
return tomlkit.dumps(Doc)