Files
SaraSuite/Manifests.py
T
sarahandClaude Opus 5.5 6e4c571e38 Add SaraSort, listed under SaraSortd
Manifests can name a Parent program; it's listed (indented) right under it,
in the window and in `list`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:57:49 +02:00

233 lines
7.9 KiB
Python

"""Program manifests: Programs/<Name>/Manifest.toml, plus optional Templates/
and Hooks.py next to it. See README.md for the full manifest format."""
import importlib.util
import re
from dataclasses import dataclass, field
from pathlib import Path
from types import ModuleType
from typing import Optional
import tomlkit
from Core import (
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ManifestsDir, ProgramsDir,
SharedConfigDir,
)
# Never worth copying out of a source checkout, whatever Include says.
DefaultExclude = [
".git", ".venv", "__pycache__", "*.pyc", "build", "dist", ".flet", ".flatpak-builder",
]
@dataclass
class Option:
"""A checkbox in the install dialog. Templates, IniEdits and Services
that name it are only applied while it's ticked."""
Id: str
Label: str
Default: bool = False
@dataclass
class Question:
"""A value asked for at install time, usable as {Id} in templates and
config Set values. Kind "Path" expands ~ and makes it absolute."""
Id: str
Prompt: str
Default: str = ""
Kind: str = "Text"
@dataclass
class TemplateFile:
"""A file SaraSuite writes and fully owns (desktop entries, units, launchers).
Rewritten on every update; the user isn't expected to edit these."""
Source: str # relative to the program's Templates/ folder
Target: str
Option: str = ""
Root: bool = False # lives outside $HOME, written through pkexec
Executable: bool = False
@dataclass
class ConfigFile:
"""A config the user is expected to edit. Placed once, then merged smartly
on updates (see ConfigMerge.py) instead of being overwritten."""
Source: str # relative to the program's source checkout
Target: str
Set: dict[str, str] = field(default_factory=dict) # dotted TOML key -> value
@dataclass
class IniEdit:
"""One key in a file SaraSuite doesn't own (mimeapps.list, portals.conf).
The previous value is remembered and put back on removal."""
File: str
Section: str
Key: str
Value: str
Option: str = ""
@dataclass
class Service:
Unit: str # a systemd --user unit, placed by a Template
Option: str = "" # enabled + started only while this option is ticked
@dataclass
class Leftover:
"""Something the program makes by itself while running (caches, logs,
its own config dir). Only touched on removal.
Kind: "Runtime" or "Config" (only removed when "Remove configs" is ticked).
Mode: "Remove" (delete it, directories included) or "IfEmpty" (only
remove a directory that's empty, e.g. one that might hold the user's files).
Path may contain * globs."""
Path: str
Kind: str = "Runtime"
Mode: str = "Remove"
Note: str = ""
@dataclass
class Program:
Name: str
Description: str
Folder: Path # the manifest's own folder
Parent: str = "" # a program this one belongs to (e.g. SaraSort -> SaraSortd): listed indented under it
Git: str = "" # e.g. "{GitHost}/SaraSortd"
Branch: str = "" # empty = the repo's default branch
Include: list[str] = field(default_factory=lambda: ["**"])
Exclude: list[str] = field(default_factory=list)
VersionFile: str = ""
VersionPattern: str = ""
Requirements: list[str] = field(default_factory=list)
RequirementsFile: str = ""
Options: list[Option] = field(default_factory=list)
Questions: list[Question] = field(default_factory=list)
Templates: list[TemplateFile] = field(default_factory=list)
Configs: list[ConfigFile] = field(default_factory=list)
IniEdits: list[IniEdit] = field(default_factory=list)
Services: list[Service] = field(default_factory=list)
RestartUnits: list[str] = field(default_factory=list)
Leftovers: list[Leftover] = field(default_factory=list)
Hooks: Optional[ModuleType] = None
@property
def ProgramDir(self) -> Path:
return ProgramsDir / self.Name
@property
def AppDir(self) -> Path:
return self.ProgramDir / "App"
@property
def ConfigDir(self) -> Path:
return SharedConfigDir / self.Name
@property
def GitUrl(self) -> str:
return Render(self.Git, self.Context())
@property
def UsesPython(self) -> bool:
return bool(self.Requirements or self.RequirementsFile)
def OptionEnabled(self, OptionId: str, Options: dict[str, bool]) -> bool:
return not OptionId or Options.get(OptionId, False)
def Context(self, Answers: Optional[dict[str, str]] = None) -> dict[str, str]:
"""The {Variable} values templates, targets and Set values can use."""
Desktop = CurrentDesktop()
Context = {
"Name": self.Name,
"GitHost": LoadConfig().GitHost.rstrip("/"),
"Home": str(Home),
"ConfigHome": str(ConfigHome),
"DataHome": str(DataHome),
"CacheHome": str(CacheHome),
"SystemdUserDir": str(ConfigHome / "systemd" / "user"),
"ApplicationsDir": str(DataHome / "applications"),
"DBusServicesDir": str(DataHome / "dbus-1" / "services"),
"ProgramDir": str(self.ProgramDir),
"AppDir": str(self.AppDir),
"Python": str(self.ProgramDir / ".venv" / "bin" / "python"),
"ConfigDir": str(self.ConfigDir),
"DesktopName": Desktop,
"Desktop": Desktop.lower(),
}
Context.update(Answers or {})
return Context
def Render(Text: str, Context: dict[str, str]) -> str:
"""Replace {Variable}s. Unknown names are left alone, so shell's ${VAR}
or a literal {thing} survive untouched."""
return re.sub(r"\{(\w+)\}", lambda Match: Context.get(Match[1], Match[0]), Text)
def _LoadHooks(Folder: Path, Name: str) -> Optional[ModuleType]:
HooksPath = Folder / "Hooks.py"
if not HooksPath.exists():
return None
Spec = importlib.util.spec_from_file_location(f"SaraSuiteHooks{Name}", HooksPath)
Module = importlib.util.module_from_spec(Spec)
Spec.loader.exec_module(Module)
return Module
def LoadProgram(Folder: Path) -> Program:
Data = tomlkit.parse((Folder / "Manifest.toml").read_text()).unwrap()
Source = Data.get("Source", {})
Version = Data.get("Version", {})
Python = Data.get("Python", {})
return Program(
Name=Data["Name"],
Description=Data.get("Description", ""),
Folder=Folder,
Parent=Data.get("Parent", ""),
Git=Source["Git"],
Branch=Source.get("Branch", ""),
Include=Source.get("Include", ["**"]),
Exclude=DefaultExclude + Source.get("Exclude", []),
VersionFile=Version.get("File", ""),
VersionPattern=Version.get("Pattern", ""),
Requirements=Python.get("Requirements", []),
RequirementsFile=Python.get("RequirementsFile", ""),
Options=[Option(**Item) for Item in Data.get("Options", [])],
Questions=[Question(**Item) for Item in Data.get("Questions", [])],
Templates=[TemplateFile(**Item) for Item in Data.get("Templates", [])],
Configs=[ConfigFile(**Item) for Item in Data.get("Configs", [])],
IniEdits=[IniEdit(**Item) for Item in Data.get("IniEdits", [])],
Services=[Service(**Item) for Item in Data.get("Services", [])],
RestartUnits=Data.get("RestartUnits", []),
Leftovers=[Leftover(**Item) for Item in Data.get("Leftovers", [])],
Hooks=_LoadHooks(Folder, Data["Name"]),
)
def LoadPrograms() -> list[Program]:
"""Alphabetical, with each program's children (Parent = its Name) right
after it. A child whose parent isn't there keeps its own place."""
Loaded = [
LoadProgram(Folder)
for Folder in sorted(ManifestsDir.iterdir())
if (Folder / "Manifest.toml").exists()
]
Names = {Current.Name for Current in Loaded}
Ordered = []
for Current in Loaded:
if Current.Parent in Names:
continue
Ordered.append(Current)
Ordered.extend(Child for Child in Loaded if Child.Parent == Current.Name)
return Ordered