Files

121 lines
4.2 KiB
Python

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