Files
SaraTag/Lexicons.py
T
2026-10-09 14:21:42 +02:00

374 lines
17 KiB
Python

"""Editing a Lexicon: its options (Inherit, Source, ExcludeTags) and tags.
With Inherit on, a Lexicon starts from its Source's tags. Each tag then shows
where it comes from: Local (only here), Inherited (only in the Source),
Overridden (inherited, with some properties changed here; only the changed
properties are written) or Excluded (inherited but listed in ExcludeTags).
Saved in the same layout the terminal version wrote.
"""
import copy
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from Library import DataError, LexiconsDir, ReadToml, TagCore, TagTypes, TagType, TomlValue, WriteAtomically
# Properties each type has, in the order they're shown. Description, Required,
# ForceDefault and Default come first for every type.
CommonProperties = ["Description", "Required", "ForceDefault", "Default"]
TypeProperties = {
"Text": [],
"Number": ["Minimum", "Maximum"],
"Rating": ["Minimum", "Maximum", "FilledSymbol", "EmptySymbol"],
"List": ["Values"],
"Boolean": [],
}
PropertyHelp = {
"Description": "What the tag is for.",
"Required": "Books must have a real value (not ?).",
"ForceDefault": "Every Book is set to Default on each update; the value can't really be edited.",
"Default": "The value new Books start with.",
"Minimum": "The lowest allowed value.",
"Maximum": "The highest allowed value (also how many symbols a Rating shows).",
"FilledSymbol": "Shown once per point of a Rating.",
"EmptySymbol": "Fills a Rating up to Maximum.",
"Values": "The choices, in order (sorting follows this order).",
}
def StarterDefinition(Kind: str) -> dict[str, Any]:
Definition: dict[str, Any] = {"Description": "", "Type": Kind, "Required": False, "ForceDefault": False}
if Kind == "Rating":
Definition.update({"Minimum": 0, "Maximum": 5, "FilledSymbol": "★", "EmptySymbol": "☆"})
elif Kind == "Boolean":
Definition["Default"] = False
elif Kind == "List":
Definition["Values"] = []
return Definition
def ValidateDefinition(TagName: str, Definition: dict[str, Any]) -> str | None:
Kind = TagType(Definition)
if not Kind:
return f"{TagName} has an unknown Type {Definition.get('Type')!r}."
if not isinstance(Definition.get("Description", ""), str):
return f"{TagName}.Description must be text."
for Name in ("Required", "ForceDefault"):
if not isinstance(Definition.get(Name, False), bool):
return f"{TagName}.{Name} must be Yes or No."
if Definition.get("ForceDefault") and "Default" not in Definition:
return f"{TagName}: ForceDefault needs a Default."
Default = Definition.get("Default")
if Kind in ("Number", "Rating"):
Whole = Kind == "Rating"
Values = {Name: Definition.get(Name) for Name in ("Minimum", "Maximum", "Default")}
for Name, Value in Values.items():
if Value is None:
continue
if isinstance(Value, bool) or not isinstance(Value, int if Whole else (int, float)):
return f"{TagName}.{Name} must be a {'whole number' if Whole else 'number'}."
if Whole and Value < 0:
return f"{TagName}.{Name} can't be negative."
Minimum = Values["Minimum"] if Values["Minimum"] is not None else (0 if Whole else None)
Maximum = Values["Maximum"]
if Minimum is not None and Maximum is not None and Minimum > Maximum:
return f"{TagName}: Minimum is above Maximum."
if Default is not None and Minimum is not None and Default < Minimum:
return f"{TagName}: Default is below Minimum."
if Default is not None and Maximum is not None and Default > Maximum:
return f"{TagName}: Default is above Maximum."
if Whole and (not isinstance(Definition.get("FilledSymbol", "★"), str) or not Definition.get("FilledSymbol", "★")):
return f"{TagName}.FilledSymbol can't be empty."
elif Kind == "List":
Values = Definition.get("Values", [])
if not isinstance(Values, list):
return f"{TagName}.Values must be a list."
if len(set(map(str, Values))) != len(Values):
return f"{TagName}.Values has the same value twice."
if Default is not None and Default not in Values:
return f"{TagName}: Default isn't one of its Values."
if Definition.get("Required") and not Values:
return f"{TagName} can't be Required with no Values."
elif Kind == "Text" and Default is not None and not isinstance(Default, str):
return f"{TagName}.Default must be text."
elif Kind == "Boolean" and Default is not None and not isinstance(Default, bool):
return f"{TagName}.Default must be Yes or No."
return None
def ConvertDefinition(Definition: dict[str, Any], NewKind: str) -> dict[str, Any]:
"""The definition as another Type: shared properties stay, the Default
is converted when it can be, type-specific ones are replaced."""
Result = StarterDefinition(NewKind)
for Name in ("Description", "Required", "ForceDefault"):
if Name in Definition:
Result[Name] = Definition[Name]
OldKind = TagType(Definition)
if OldKind in ("Number", "Rating") and NewKind in ("Number", "Rating"):
for Name in ("Minimum", "Maximum"):
if Name in Definition:
Result[Name] = int(Definition[Name]) if NewKind == "Rating" else Definition[Name]
if OldKind == "List" and NewKind == "List":
Result["Values"] = list(Definition.get("Values", []))
Default = Definition.get("Default")
if Default is not None:
try:
if NewKind == "Text":
Result["Default"] = str(Default)
elif NewKind == "Number":
Result["Default"] = float(Default) if isinstance(Default, float) or "." in str(Default) else int(Default)
elif NewKind == "Rating":
Result["Default"] = max(0, int(Default))
elif NewKind == "Boolean":
Result["Default"] = bool(Default) if isinstance(Default, (bool, int)) else str(Default).casefold() in ("true", "yes", "1")
elif NewKind == "List":
Result["Values"] = Result.get("Values") or [str(Default)]
Result["Default"] = str(Default) if str(Default) in map(str, Result["Values"]) else Result["Values"][0]
except (TypeError, ValueError):
pass
if Result.get("ForceDefault") and "Default" not in Result:
Result["ForceDefault"] = False
return Result
@dataclass
class TagView:
Name: str
Origin: str # Local, Inherited, Overridden, Excluded
Definition: dict[str, Any] # effective
Inherited: dict[str, Any] # the Source's definition ({} for Local)
def PropertyOrigin(self, Name: str, Local: dict[str, Any]) -> str:
if self.Origin == "Local":
return "Local"
return "Overridden" if Name in Local else "Inherited"
class Lexicon:
def __init__(self, FilePath: Path):
self.Path = FilePath
self.Reload()
def Reload(self) -> None:
self.Data = ReadToml(self.Path)
if not isinstance(self.Data.get("Lexicon"), dict):
self.Data["Lexicon"] = {"ConfigVersion": 1, "Inherit": False}
if not isinstance(self.Data.get("Tags"), dict):
self.Data["Tags"] = {}
self.Rebuild()
@property
def Options(self) -> dict[str, Any]:
return self.Data["Lexicon"]
@property
def Local(self) -> dict[str, dict[str, Any]]:
return self.Data["Tags"]
@property
def Excluded(self) -> list[str]:
return [Name for Name in self.Options.get("ExcludeTags", []) or [] if isinstance(Name, str)]
@property
def SourcePath(self) -> Path | None:
Source = self.Options.get("Source")
if not self.Options.get("Inherit") or not isinstance(Source, str) or not Source.strip() or Source == "None":
return None
return TagCore.ResolveLexiconSourcePath(self.Path, Source.strip())
def Rebuild(self) -> None:
"""Works out every tag's origin and effective definition."""
self.SourceError = ""
SourceTags: dict[str, dict[str, Any]] = {}
if self.SourcePath is not None:
try:
SourceTags = TagCore.ResolveLexiconTags(self.SourcePath)
except (OSError, ValueError) as Error:
self.SourceError = str(Error)
self.Views: list[TagView] = []
for Name, Inherited in SourceTags.items():
if Name in self.Excluded:
self.Views.append(TagView(Name, "Excluded", copy.deepcopy(Inherited), Inherited))
elif Name in self.Local:
self.Views.append(TagView(Name, "Overridden" if self.Local[Name] else "Inherited",
TagCore.MergeTagDefinition(Inherited, self.Local[Name]), Inherited))
else:
self.Views.append(TagView(Name, "Inherited", copy.deepcopy(Inherited), Inherited))
for Name, Definition in self.Local.items():
if Name not in SourceTags and isinstance(Definition, dict):
self.Views.append(TagView(Name, "Local", copy.deepcopy(Definition), {}))
def View(self, Name: str) -> TagView | None:
return next((Item for Item in self.Views if Item.Name == Name), None)
def Save(self) -> None:
Lines = ["# SaraTag Lexicon", "", "[Lexicon]"]
Lines += [f"{Key} = {TomlValue(Value)}" for Key, Value in self.Options.items()]
for Name, Definition in self.Local.items():
Lines += ["", f"[Tags.{TomlKey(Name)}]"]
for Key, Value in Definition.items():
if Key == "Values" and isinstance(Value, list):
Lines += ["Values = ["] + [f" {TomlValue(Item)}," for Item in Value] + ["]"]
else:
Lines.append(f"{Key} = {TomlValue(Value)}")
try:
WriteAtomically(self.Path, "\n".join(Lines).rstrip() + "\n")
except OSError as Error:
raise DataError(f"Couldn't save {self.Path.name}: {Error}") from Error
self.Rebuild()
def _Commit(self, Check: str | None = None) -> None:
"""Validates the effective tags, then saves; undoes the change on failure."""
self.Rebuild()
for Item in self.Views:
Problem = ValidateDefinition(Item.Name, Item.Definition) if Item.Origin != "Excluded" else None
if Problem and (Check is None or Item.Name == Check):
self.Reload()
raise DataError(Problem)
try:
self.Save()
except DataError:
self.Reload()
raise
# Options
def SetOptions(self, Inherit: bool, Source: str) -> None:
self.Options["Inherit"] = Inherit
if Source.strip():
self.Options["Source"] = Source.strip()
else:
self.Options.pop("Source", None)
if Inherit and self.SourcePath is None:
self.Reload()
raise DataError("Inherit needs a Source Lexicon.")
if Inherit and self.SourcePath is not None and self.SourcePath.resolve() == self.Path.resolve():
self.Reload()
raise DataError("A Lexicon can't inherit from itself.")
self.Rebuild()
if self.SourceError:
Error = self.SourceError
self.Reload()
raise DataError(f"Can't use that Source: {Error}")
self._Commit()
# Tags
def AddTag(self, Name: str, Kind: str) -> None:
Name = Name.strip()
if not Name or not all(Character.isalnum() or Character in "_-" for Character in Name):
raise DataError("Tag names can use letters, digits, _ and -.")
if Name == "Parent" or any(Item.Name.casefold() == Name.casefold() for Item in self.Views):
raise DataError(f"There's already a tag called {Name}.")
if Kind not in TagTypes:
raise DataError(f"Unknown type {Kind}.")
self.Local[Name] = StarterDefinition(Kind)
self._Commit(Name)
def RenameTag(self, OldName: str, NewName: str) -> None:
"""Local tags only; inherited ones are named by their Source."""
View = self.View(OldName)
if View is None or View.Origin != "Local":
raise DataError("Only local tags can be renamed; rename inherited ones in their Source.")
NewName = NewName.strip()
if NewName == OldName:
return
if not NewName or not all(Character.isalnum() or Character in "_-" for Character in NewName):
raise DataError("Tag names can use letters, digits, _ and -.")
if NewName == "Parent" or any(Item.Name.casefold() == NewName.casefold() and Item.Name != OldName for Item in self.Views):
raise DataError(f"There's already a tag called {NewName}.")
self.Data["Tags"] = {(NewName if Key == OldName else Key): Value for Key, Value in self.Local.items()}
self._Commit()
def DeleteTag(self, Name: str) -> None:
"""Deletes a local tag; an inherited one is excluded instead."""
View = self.View(Name)
if View is None:
return
if View.Origin == "Local":
del self.Local[Name]
else:
self.Local.pop(Name, None)
self.Options["ExcludeTags"] = self.Excluded + [Name]
self._Commit()
def RestoreTag(self, Name: str) -> None:
Remaining = [Item for Item in self.Excluded if Item != Name]
if Remaining:
self.Options["ExcludeTags"] = Remaining
else:
self.Options.pop("ExcludeTags", None)
self._Commit()
def MoveTag(self, Name: str, Offset: int) -> None:
"""Reorders local tags (the order Books are written in)."""
Names = list(self.Local)
if Name not in Names:
raise DataError("Only local tags can be moved; inherited ones follow their Source.")
Index = Names.index(Name)
Target = max(0, min(len(Names) - 1, Index + Offset))
Names.insert(Target, Names.pop(Index))
self.Data["Tags"] = {Key: self.Local[Key] for Key in Names}
self.Save()
def SetProperty(self, Name: str, Property: str, Value: Any) -> None:
"""Sets (or with Value None, removes) one property. On an inherited tag
this writes an override holding only what differs from the Source."""
View = self.View(Name)
if View is None:
raise DataError(f"{Name} doesn't exist any more.")
Local = dict(self.Local.get(Name, {}))
if Value is None:
Local.pop(Property, None)
elif View.Origin != "Local" and View.Inherited.get(Property) == Value and type(View.Inherited.get(Property)) is type(Value):
Local.pop(Property, None) # same as inherited: no override needed
else:
Local[Property] = Value
if View.Origin == "Local" or Local:
self.Local[Name] = Local
else:
self.Local.pop(Name, None)
self._Commit(Name)
def ChangeType(self, Name: str, NewKind: str) -> None:
View = self.View(Name)
if View is None or View.Origin != "Local":
raise DataError("Only local tags can change type.")
self.Local[Name] = ConvertDefinition(View.Definition, NewKind)
self._Commit(Name)
def TomlKey(Name: str) -> str:
return Name if Name and all(Character.isalnum() or Character in "_-" for Character in Name) else TomlValue(Name)
def KnownLexicons(Directories: list[Path]) -> list[Path]:
"""Every Lexicon in Lexicons/, plus the ones inside the given Record
directories and their subdirectories."""
Found: dict[Path, None] = {}
if LexiconsDir.is_dir():
for FilePath in sorted(LexiconsDir.glob("*.toml"), key=lambda Item: Item.name.casefold()):
Found[FilePath.resolve()] = None
for Directory in Directories:
for Root, Names, _ in os.walk(Directory, onerror=lambda Error: None):
Names[:] = sorted(Name for Name in Names if not Name.startswith("."))
Candidate = TagCore.GetLexiconPath(Path(Root))
if Candidate.is_file():
Found[Candidate.resolve()] = None
return list(Found)
def NewSharedLexicon(Name: str) -> Path:
"""An empty Lexicon in Lexicons/ for other Lexicons to inherit from."""
Name = Name.strip().removesuffix(".toml")
if not Name or "/" in Name or "\\" in Name or Name.startswith("."):
raise DataError("That isn't a valid file name.")
LexiconsDir.mkdir(parents=True, exist_ok=True)
FilePath = LexiconsDir / f"{Name}.toml"
if FilePath.exists():
raise DataError(f"{FilePath.name} already exists.")
WriteAtomically(FilePath, "# SaraTag Lexicon\n\n[Lexicon]\nConfigVersion = 1\nInherit = false\n")
return FilePath