mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
127 lines
4.9 KiB
Python
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)
|