"""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