"""SaraTag's data library, .saratag/Core.py, loaded the same way SaraFem and SaraView load it (by file path, as "SaraTagCore"). That file stays stdlib-only and keeps its location and function names, because SaraFem and SaraView point their CoreModulePath at .saratag/. Also the shared helpers on top of it: reading and writing TOML, Books and SaraTagConf.toml. """ import importlib.util import os import sys import tomllib from pathlib import Path from typing import Any import tomlkit _CorePath = Path(__file__).resolve().parent / ".saratag" / "Core.py" _Spec = importlib.util.spec_from_file_location("SaraTagCore", _CorePath) TagCore = importlib.util.module_from_spec(_Spec) sys.modules["SaraTagCore"] = TagCore _Spec.loader.exec_module(TagCore) DataDir: Path = TagCore.ApplicationDirectory ConfPath: Path = TagCore.SaraTagConfPath RecordsPath: Path = TagCore.SaraTagRecordsPath HistoryPath: Path = TagCore.SaraTagHistoryPath LexiconsDir = DataDir / "Lexicons" TagTypes = ["Text", "Number", "Rating", "List", "Boolean"] Unset = "?" # a Book value nobody has filled in yet class DataError(Exception): """A SaraTag data file that can't be read or written; the message is shown as is.""" def ReadToml(FilePath: Path) -> dict[str, Any]: try: with FilePath.open("rb") as TomlFile: return tomllib.load(TomlFile) except tomllib.TOMLDecodeError as Error: raise DataError(f"Invalid TOML in {FilePath.name}: {Error}") from Error except OSError as Error: raise DataError(f"Couldn't read {FilePath}: {Error}") from Error def WriteAtomically(FilePath: Path, Text: str) -> None: """Write through a temporary sibling, so a crash never leaves half a file.""" TemporaryPath = FilePath.with_name(f"{FilePath.name}.tmp") try: TemporaryPath.write_text(Text, encoding="utf-8") TemporaryPath.replace(FilePath) finally: TemporaryPath.unlink(missing_ok=True) def TomlValue(Value: Any) -> str: """One value in TOML syntax, escaped properly (newlines and all).""" return tomlkit.item(Value).as_string() def TagType(Definition: dict[str, Any]) -> str: """"Text", "Number", ... as written in TagTypes, whatever the case in the file.""" Raw = str(Definition.get("Type", "")).strip().casefold() return next((Name for Name in TagTypes if Name.casefold() == Raw), "") def Bound(Definition: dict[str, Any], Name: str) -> Any: """Minimum or Maximum (also accepting the old Min/Max spelling), or None.""" Value = Definition.get(Name, Definition.get(Name[:3])) if isinstance(Value, bool) or not isinstance(Value, (int, float)): return None return Value def FormatValue(Value: Any, Definition: dict[str, Any] | None = None) -> str: """A Book value for display: ratings as symbols, booleans as Yes/No.""" if Definition and TagType(Definition) == "Rating" and isinstance(Value, int) and not isinstance(Value, bool): return TagCore.FormatRating(Value, Definition) if isinstance(Value, bool): return "Yes" if Value else "No" if isinstance(Value, list): return ", ".join(str(Item) for Item in Value) return str(Value) # SaraTagConf.toml def LoadConf() -> tomlkit.TOMLDocument: TagCore.EnsureConfigFiles() try: return tomlkit.parse(ConfPath.read_text(encoding="utf-8")) except (OSError, tomlkit.exceptions.TOMLKitError) as Error: raise DataError(f"Couldn't read {ConfPath.name}: {Error}") from Error def ConfValue(Section: str, Key: str, Fallback: Any) -> Any: try: Table = LoadConf().get(Section, {}) except DataError: return Fallback Value = Table.get(Key, Fallback) if isinstance(Table, dict) else Fallback return Value.unwrap() if hasattr(Value, "unwrap") else Value def SaveConfValues(Values: dict[str, dict[str, Any]]) -> None: """{Section: {Key: Value}} into SaraTagConf.toml, keeping its comments.""" Document = LoadConf() for Section, Keys in Values.items(): if Section not in Document: Document[Section] = tomlkit.table() for Key, Value in Keys.items(): Document[Section][Key] = Value WriteAtomically(ConfPath, tomlkit.dumps(Document)) def ExpandUserPath(Text: str) -> Path: return Path(os.path.expanduser(Text.strip()))