mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-15 10:33:17 +02:00
Initial commit
This commit is contained in:
commit
bea0f43947
25 files changed
+2327
No files matched your search
+641
@@ -0,0 +1,641 @@
|
||||
"""Install, update and remove programs described by a manifest.
|
||||
|
||||
Install and Update are the same operation (_Apply): make everything match the
|
||||
manifest, recording each placed piece in the Receipt as it goes. Remove walks
|
||||
the Receipt plus the program's Leftovers and takes all of it back out.
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from glob import glob
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ConfigMerge import ApplySet, MergeConfig, MergeResult
|
||||
from Core import BackupsDir, ChildEnvironment, DownloadsDir, PristineDir
|
||||
from Manifests import Leftover, Program, Render
|
||||
from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt
|
||||
from Sources import ClearDownloads, Download, LatestCommit, SourceError
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
class InstallError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# --- Small helpers ------------------------------------------------------------
|
||||
|
||||
|
||||
def _Now() -> str:
|
||||
return datetime.datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] = None) -> int:
|
||||
"""Run a command, streaming its output into the log."""
|
||||
Log(f"$ {' '.join(Command)}")
|
||||
Process = subprocess.Popen(
|
||||
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
|
||||
)
|
||||
for Line in Process.stdout:
|
||||
Log(f" {Line.rstrip()}")
|
||||
Code = Process.wait()
|
||||
if Check and Code != 0:
|
||||
raise InstallError(f"{Command[0]} failed (exit code {Code})")
|
||||
return Code
|
||||
|
||||
|
||||
def _Systemctl(Log: LogFn, *Args: str, Check: bool = False) -> int:
|
||||
return _Run(["systemctl", "--user", *Args], Log, Check=Check)
|
||||
|
||||
|
||||
def _RemovePath(Target: Path) -> None:
|
||||
if Target.is_symlink() or Target.is_file():
|
||||
Target.unlink()
|
||||
elif Target.is_dir():
|
||||
shutil.rmtree(Target)
|
||||
|
||||
|
||||
def _BackupName(Target: str) -> str:
|
||||
"""A flat, unique file name for backing up an absolute path."""
|
||||
return Target.strip("/").replace("/", "__")
|
||||
|
||||
|
||||
def _WriteRoot(Target: str, Text: str, Log: LogFn) -> None:
|
||||
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sarasuite") as Temporary:
|
||||
Temporary.write(Text)
|
||||
os.chmod(Temporary.name, 0o644)
|
||||
try:
|
||||
_Run(["pkexec", "install", "-D", "-m", "644", Temporary.name, Target], Log)
|
||||
finally:
|
||||
os.unlink(Temporary.name)
|
||||
|
||||
|
||||
def _RemoveRoot(Target: str, Log: LogFn) -> None:
|
||||
_Run(["pkexec", "rm", "-f", Target], Log)
|
||||
|
||||
|
||||
def _ReadIni(FilePath: Path) -> configparser.ConfigParser:
|
||||
Parser = configparser.ConfigParser(interpolation=None, strict=False, delimiters=("=",))
|
||||
Parser.optionxform = str # keep key case (MIME types, D-Bus names)
|
||||
if FilePath.exists():
|
||||
Parser.read(FilePath)
|
||||
return Parser
|
||||
|
||||
|
||||
def _WriteIni(FilePath: Path, Parser: configparser.ConfigParser) -> None:
|
||||
FilePath.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FilePath.open("w") as File:
|
||||
Parser.write(File, space_around_delimiters=False)
|
||||
|
||||
|
||||
# --- Sources ------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fetched:
|
||||
"""One downloaded commit of a program, ready to install from."""
|
||||
|
||||
Commit: str
|
||||
Dir: Path
|
||||
|
||||
|
||||
def Fetch(Program: Program, Log: LogFn) -> Fetched:
|
||||
Commit = LatestCommit(Program.GitUrl, Program.Branch)
|
||||
return Fetched(Commit, Download(Program.Name, Program.GitUrl, Commit, Log))
|
||||
|
||||
|
||||
def SourceFiles(Program: Program, SourceDir: Path) -> list[PurePosixPath]:
|
||||
"""Relative paths of the files to copy, per the manifest's Include/Exclude."""
|
||||
Found = []
|
||||
for DirPath, DirNames, FileNames in os.walk(SourceDir):
|
||||
RelDir = PurePosixPath(Path(DirPath).relative_to(SourceDir).as_posix())
|
||||
DirNames[:] = sorted(
|
||||
Name for Name in DirNames
|
||||
if not any((RelDir / Name).full_match(Pattern) or Name == Pattern for Pattern in Program.Exclude)
|
||||
)
|
||||
for Name in sorted(FileNames):
|
||||
Rel = RelDir / Name if str(RelDir) != "." else PurePosixPath(Name)
|
||||
if any(Rel.full_match(Pattern) or Name == Pattern for Pattern in Program.Exclude):
|
||||
continue
|
||||
if any(Rel.full_match(Pattern) for Pattern in Program.Include):
|
||||
Found.append(Rel)
|
||||
return Found
|
||||
|
||||
|
||||
def ManifestHash(Program: Program) -> str:
|
||||
"""Changes when the bundled manifest/templates change (a new SaraSuite
|
||||
AppImage), so that shows up as an update even with no new commit."""
|
||||
Hasher = hashlib.sha256()
|
||||
for ManifestFile in sorted(Program.Folder.rglob("*")):
|
||||
if ManifestFile.is_file() and "__pycache__" not in ManifestFile.parts:
|
||||
Hasher.update(str(ManifestFile.relative_to(Program.Folder)).encode())
|
||||
Hasher.update(ManifestFile.read_bytes())
|
||||
return Hasher.hexdigest()
|
||||
|
||||
|
||||
def SourceVersion(Program: Program, SourceDir: Path) -> str:
|
||||
if not Program.VersionFile:
|
||||
return ""
|
||||
VersionPath = SourceDir / Program.VersionFile
|
||||
if not VersionPath.exists():
|
||||
return ""
|
||||
Match = re.search(Program.VersionPattern, VersionPath.read_text(errors="replace"))
|
||||
return Match[1] if Match else ""
|
||||
|
||||
|
||||
# --- Status -------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
Receipt: Optional[Receipt]
|
||||
LatestCommit: str = "" # empty when the git host couldn't be reached
|
||||
ManifestHash: str = ""
|
||||
Error: str = ""
|
||||
|
||||
@property
|
||||
def Installed(self) -> bool:
|
||||
return self.Receipt is not None
|
||||
|
||||
@property
|
||||
def Broken(self) -> bool:
|
||||
return self.Receipt is not None and not self.Receipt.Complete
|
||||
|
||||
@property
|
||||
def UpdateAvailable(self) -> bool:
|
||||
if self.Receipt is None:
|
||||
return False
|
||||
NewCommit = bool(self.LatestCommit) and self.LatestCommit != self.Receipt.Commit
|
||||
return self.Broken or NewCommit or self.ManifestHash != self.Receipt.ManifestHash
|
||||
|
||||
|
||||
def GetStatus(Program: Program) -> Status:
|
||||
"""Installed state from the receipt, plus the latest commit from the git
|
||||
host (one small HTTPS request, no download)."""
|
||||
Current = Status(Receipt=LoadReceipt(Program.Name), ManifestHash=ManifestHash(Program))
|
||||
try:
|
||||
Current.LatestCommit = LatestCommit(Program.GitUrl, Program.Branch)
|
||||
except SourceError as Error:
|
||||
Current.Error = str(Error)
|
||||
return Current
|
||||
|
||||
|
||||
# --- Configs --------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigPlan:
|
||||
Target: str
|
||||
Result: MergeResult
|
||||
Pristine: str # the new default, saved as the next merge's Base
|
||||
|
||||
|
||||
def PlanConfigs(Program: Program, SourceDir: Path, Answers: dict[str, str]) -> list[ConfigPlan]:
|
||||
"""What an install/update would do to each config (also shown in the
|
||||
Update dialog before anything is touched)."""
|
||||
Context = Program.Context(Answers)
|
||||
Plans = []
|
||||
for Config in Program.Configs:
|
||||
Target = Render(Config.Target, Context)
|
||||
ConfigSource = SourceDir / Config.Source
|
||||
if not ConfigSource.exists():
|
||||
raise InstallError(f"Config {Config.Source} is missing from {SourceDir}")
|
||||
Theirs = ConfigSource.read_text()
|
||||
Theirs = ApplySet(Theirs, {Key: Render(Value, Context) for Key, Value in Config.Set.items()})
|
||||
|
||||
PristinePath = PristineDir / Program.Name / Path(Target).name
|
||||
Base = PristinePath.read_text() if PristinePath.exists() else None
|
||||
Ours = Path(Target).read_text() if Path(Target).exists() else None
|
||||
Plans.append(ConfigPlan(Target, MergeConfig(Base, Ours, Theirs, Target.endswith(".toml")), Theirs))
|
||||
return Plans
|
||||
|
||||
|
||||
def _ApplyConfigs(Program: Program, Plans: list[ConfigPlan], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
MergeBackups = BackupsDir / Program.Name / "Configs"
|
||||
Stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
for Plan in Plans:
|
||||
Target = Path(Plan.Target)
|
||||
Result = Plan.Result
|
||||
Log(f"Config {Target.name}: {Result.Describe()}")
|
||||
for Change in Result.Changes:
|
||||
Log(f" - {Change}")
|
||||
|
||||
if Result.Text is not None:
|
||||
Target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if Result.Action == "Merge" and Target.exists():
|
||||
MergeBackups.mkdir(parents=True, exist_ok=True)
|
||||
BackupPath = MergeBackups / f"{Target.name}.{Stamp}"
|
||||
shutil.copy2(Target, BackupPath)
|
||||
Log(f" (your previous version is backed up at {BackupPath})")
|
||||
Target.write_text(Result.Text)
|
||||
|
||||
NewFile = Target.with_name(Target.name + ".new")
|
||||
if Result.Action == "KeepAndNew":
|
||||
NewFile.write_text(Plan.Pristine)
|
||||
NewReceipt.Files.append(PlacedFile(str(NewFile), "ConfigNew"))
|
||||
|
||||
PristinePath = PristineDir / Program.Name / Target.name
|
||||
PristinePath.parent.mkdir(parents=True, exist_ok=True)
|
||||
PristinePath.write_text(Plan.Pristine)
|
||||
NewReceipt.Files.append(PlacedFile(str(Target), "Config"))
|
||||
|
||||
|
||||
# --- Install / Update -------------------------------------------------------------
|
||||
|
||||
|
||||
def _CopyProgramFiles(Program: Program, SourceDir: Path, Log: LogFn) -> None:
|
||||
Files = SourceFiles(Program, SourceDir)
|
||||
if not Files:
|
||||
raise InstallError(f"No files matched Include {Program.Include} in {SourceDir}")
|
||||
Staging = Program.ProgramDir / "App.new"
|
||||
_RemovePath(Staging)
|
||||
for Rel in Files:
|
||||
Destination = Staging / Rel
|
||||
Destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(SourceDir / Rel, Destination)
|
||||
_RemovePath(Program.AppDir)
|
||||
Staging.rename(Program.AppDir)
|
||||
Log(f"Copied {len(Files)} program files to {Program.AppDir}")
|
||||
|
||||
|
||||
def _RequirementsText(Program: Program) -> str:
|
||||
if Program.RequirementsFile:
|
||||
return (Program.AppDir / Program.RequirementsFile).read_text()
|
||||
return "\n".join(Program.Requirements)
|
||||
|
||||
|
||||
def _SetUpVenv(Program: Program, OldHash: str, Log: LogFn) -> str:
|
||||
"""(Re)install requirements only when they changed. Returns the new hash."""
|
||||
if not Program.UsesPython:
|
||||
return ""
|
||||
Venv = Program.ProgramDir / ".venv"
|
||||
NewHash = hashlib.sha256(_RequirementsText(Program).encode()).hexdigest()
|
||||
if (Venv / "bin" / "python").exists() and NewHash == OldHash:
|
||||
Log("Python requirements unchanged, skipping pip")
|
||||
return NewHash
|
||||
if not (Venv / "bin" / "python").exists():
|
||||
# Always the system python: inside the AppImage sys.executable is the
|
||||
# AppImage itself, and the venv has to outlive SaraSuite anyway.
|
||||
Python = shutil.which("python3")
|
||||
if Python is None:
|
||||
raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)")
|
||||
_Run([Python, "-m", "venv", str(Venv)], Log)
|
||||
Pip = [str(Venv / "bin" / "python"), "-m", "pip", "install", "--disable-pip-version-check"]
|
||||
if Program.RequirementsFile:
|
||||
_Run([*Pip, "-r", str(Program.AppDir / Program.RequirementsFile)], Log)
|
||||
else:
|
||||
_Run([*Pip, *Program.Requirements], Log)
|
||||
return NewHash
|
||||
|
||||
|
||||
def _PutBackDisplaced(Placed: PlacedFile, Log: LogFn) -> None:
|
||||
if not Placed.Displaced or not Path(Placed.Displaced).exists():
|
||||
return
|
||||
Current = Path(Placed.Path)
|
||||
if Current.exists() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
|
||||
pass # already identical (typical for a root file that was never removed)
|
||||
elif Placed.Root:
|
||||
_WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log)
|
||||
else:
|
||||
shutil.copy2(Placed.Displaced, Placed.Path)
|
||||
Path(Placed.Displaced).unlink()
|
||||
Log(f"Restored your previous {Placed.Path}")
|
||||
|
||||
|
||||
def _RemovePlacedTemplate(Placed: PlacedFile, Log: LogFn) -> None:
|
||||
if Placed.Root and Placed.Displaced:
|
||||
pass # overwritten in place by _PutBackDisplaced: one password prompt, not two
|
||||
elif Placed.Root:
|
||||
if Path(Placed.Path).exists():
|
||||
_RemoveRoot(Placed.Path, Log)
|
||||
Log(f"Removed {Placed.Path}")
|
||||
else:
|
||||
Path(Placed.Path).unlink(missing_ok=True)
|
||||
Log(f"Removed {Placed.Path}")
|
||||
_PutBackDisplaced(Placed, Log)
|
||||
|
||||
|
||||
def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
Wanted = [Template for Template in Program.Templates if Program.OptionEnabled(Template.Option, NewReceipt.Options)]
|
||||
WantedTargets = {Render(Template.Target, Context) for Template in Wanted}
|
||||
|
||||
# Anything the old install placed that this one no longer wants (an option
|
||||
# was unticked, or the manifest dropped it).
|
||||
if OldReceipt is not None:
|
||||
for Placed in OldReceipt.Files:
|
||||
if Placed.Kind == "Template" and Placed.Path not in WantedTargets:
|
||||
_RemovePlacedTemplate(Placed, Log)
|
||||
|
||||
for Template in Wanted:
|
||||
Target = Render(Template.Target, Context)
|
||||
Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context)
|
||||
Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None
|
||||
Placed = PlacedFile(Target, "Template", Root=Template.Root, Displaced=Previous.Displaced if Previous else "")
|
||||
|
||||
# A file that's already there but wasn't placed by SaraSuite (e.g. a
|
||||
# hand-made desktop entry from development) is moved aside, not lost.
|
||||
if Previous is None and Path(Target).exists():
|
||||
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(Target)
|
||||
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Target, Displaced)
|
||||
Placed.Displaced = str(Displaced)
|
||||
Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)")
|
||||
|
||||
NewReceipt.Files.append(Placed)
|
||||
if Template.Root:
|
||||
if Path(Target).exists() and Path(Target).read_text() == Text:
|
||||
continue
|
||||
_WriteRoot(Target, Text, Log)
|
||||
else:
|
||||
Path(Target).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(Target).write_text(Text)
|
||||
if Template.Executable:
|
||||
Path(Target).chmod(0o755)
|
||||
Log(f"Wrote {Target}")
|
||||
|
||||
|
||||
def _RevertIni(Record: IniRecord, Log: LogFn) -> None:
|
||||
FilePath = Path(Record.File)
|
||||
Parser = _ReadIni(FilePath)
|
||||
if not Parser.has_section(Record.Section):
|
||||
return
|
||||
Current = Parser.get(Record.Section, Record.Key, fallback=None)
|
||||
if Current is not None and Current != Record.Value:
|
||||
Log(f"Left {Record.Key} in {FilePath} alone (it was changed after SaraSuite set it)")
|
||||
return
|
||||
if Record.HadPrevious:
|
||||
Parser.set(Record.Section, Record.Key, Record.Previous)
|
||||
Log(f"Restored {Record.Key}={Record.Previous} in {FilePath}")
|
||||
else:
|
||||
Parser.remove_option(Record.Section, Record.Key)
|
||||
if not Parser.options(Record.Section):
|
||||
Parser.remove_section(Record.Section)
|
||||
Log(f"Removed {Record.Key} from {FilePath}")
|
||||
if Parser.sections():
|
||||
_WriteIni(FilePath, Parser)
|
||||
else:
|
||||
FilePath.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _ApplyIniEdits(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
Wanted = []
|
||||
for Edit in Program.IniEdits:
|
||||
if Program.OptionEnabled(Edit.Option, NewReceipt.Options):
|
||||
Wanted.append((Render(Edit.File, Context), Edit.Section, Edit.Key, Render(Edit.Value, Context)))
|
||||
|
||||
if OldReceipt is not None:
|
||||
WantedKeys = {(File, Section, Key) for File, Section, Key, _Value in Wanted}
|
||||
for Record in OldReceipt.IniEdits:
|
||||
if (Record.File, Record.Section, Record.Key) not in WantedKeys:
|
||||
_RevertIni(Record, Log)
|
||||
|
||||
for File, Section, Key, Value in Wanted:
|
||||
Parser = _ReadIni(Path(File))
|
||||
Previous = OldReceipt.FindIni(File, Section, Key) if OldReceipt is not None else None
|
||||
if Previous is not None:
|
||||
Record = IniRecord(File, Section, Key, Value, Previous.HadPrevious, Previous.Previous)
|
||||
else:
|
||||
Existing = Parser.get(Section, Key, fallback=None) if Parser.has_section(Section) else None
|
||||
Record = IniRecord(File, Section, Key, Value, Existing is not None, Existing or "")
|
||||
NewReceipt.IniEdits.append(Record)
|
||||
if Parser.has_section(Section) and Parser.get(Section, Key, fallback=None) == Value:
|
||||
continue
|
||||
if not Parser.has_section(Section):
|
||||
Parser.add_section(Section)
|
||||
Parser.set(Section, Key, Value)
|
||||
_WriteIni(Path(File), Parser)
|
||||
Log(f"Set {Key}={Value} in {File}")
|
||||
|
||||
|
||||
def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
if not Program.Services and not (OldReceipt and OldReceipt.EnabledServices):
|
||||
return
|
||||
_Systemctl(Log, "daemon-reload")
|
||||
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
|
||||
for Service in Program.Services:
|
||||
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
|
||||
NewReceipt.EnabledServices.append(Service.Unit)
|
||||
if Service.Unit in WasEnabled:
|
||||
_Systemctl(Log, "try-restart", Service.Unit)
|
||||
else:
|
||||
_Systemctl(Log, "enable", "--now", Service.Unit, Check=True)
|
||||
elif Service.Unit in WasEnabled:
|
||||
_Systemctl(Log, "disable", "--now", Service.Unit)
|
||||
|
||||
|
||||
def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
|
||||
Applications = Path(Program.Context()["ApplicationsDir"])
|
||||
if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists():
|
||||
_Run(["update-desktop-database", str(Applications)], Log, Check=False)
|
||||
for Unit in Program.RestartUnits:
|
||||
_Systemctl(Log, "try-restart", Unit)
|
||||
|
||||
|
||||
def Apply(
|
||||
Program: Program,
|
||||
Log: LogFn,
|
||||
Options: Optional[dict[str, bool]] = None,
|
||||
Answers: Optional[dict[str, str]] = None,
|
||||
Source: Optional[Fetched] = None,
|
||||
) -> Receipt:
|
||||
"""Install (no receipt yet) or update (receipt exists) a program to the
|
||||
latest commit, or to Source if it was already fetched (the Update dialog
|
||||
fetches first to preview the config merges).
|
||||
Options/Answers are only used on install; updates keep the recorded ones."""
|
||||
OldReceipt = LoadReceipt(Program.Name)
|
||||
Action = "Updating" if OldReceipt else "Installing"
|
||||
Log(f"== {Action} {Program.Name}")
|
||||
|
||||
Source = Source or Fetch(Program, Log)
|
||||
SourceDir = Source.Dir
|
||||
NewReceipt = Receipt(
|
||||
Name=Program.Name,
|
||||
Version=SourceVersion(Program, SourceDir),
|
||||
Commit=Source.Commit,
|
||||
ManifestHash=ManifestHash(Program),
|
||||
InstalledAt=OldReceipt.InstalledAt if OldReceipt else _Now(),
|
||||
UpdatedAt=_Now(),
|
||||
Options=dict(OldReceipt.Options) if OldReceipt else dict(Options or {}),
|
||||
Answers=dict(OldReceipt.Answers) if OldReceipt else dict(Answers or {}),
|
||||
)
|
||||
# Options added to the manifest since this program was installed.
|
||||
for Option in Program.Options:
|
||||
NewReceipt.Options.setdefault(Option.Id, Option.Default)
|
||||
for Question in Program.Questions:
|
||||
NewReceipt.Answers.setdefault(Question.Id, Question.Default)
|
||||
if Question.Kind == "Path":
|
||||
NewReceipt.Answers[Question.Id] = str(Path(NewReceipt.Answers[Question.Id]).expanduser().absolute())
|
||||
Context = Program.Context(NewReceipt.Answers)
|
||||
ConfigPlans = PlanConfigs(Program, SourceDir, NewReceipt.Answers)
|
||||
|
||||
try:
|
||||
Program.ProgramDir.mkdir(parents=True, exist_ok=True)
|
||||
_CopyProgramFiles(Program, SourceDir, Log)
|
||||
NewReceipt.RequirementsHash = _SetUpVenv(Program, OldReceipt.RequirementsHash if OldReceipt else "", Log)
|
||||
_ApplyTemplates(Program, Context, OldReceipt, NewReceipt, Log)
|
||||
_ApplyConfigs(Program, ConfigPlans, NewReceipt, Log)
|
||||
# .new files from an earlier update that are still around stay tracked.
|
||||
if OldReceipt is not None:
|
||||
for Placed in OldReceipt.Files:
|
||||
if Placed.Kind == "ConfigNew" and Path(Placed.Path).exists() and NewReceipt.FindFile(Placed.Path) is None:
|
||||
NewReceipt.Files.append(Placed)
|
||||
_ApplyIniEdits(Program, Context, OldReceipt, NewReceipt, Log)
|
||||
_ApplyServices(Program, OldReceipt, NewReceipt, Log)
|
||||
_AfterIntegrationChanges(Program, Log)
|
||||
NewReceipt.Complete = True
|
||||
ClearDownloads(Program.Name)
|
||||
finally:
|
||||
if OldReceipt is not None and not NewReceipt.Complete:
|
||||
# A failure partway: keep everything the old receipt knew about too,
|
||||
# so Remove still finds every file either install placed.
|
||||
for Placed in OldReceipt.Files:
|
||||
if NewReceipt.FindFile(Placed.Path) is None:
|
||||
NewReceipt.Files.append(Placed)
|
||||
for Record in OldReceipt.IniEdits:
|
||||
if NewReceipt.FindIni(Record.File, Record.Section, Record.Key) is None:
|
||||
NewReceipt.IniEdits.append(Record)
|
||||
NewReceipt.EnabledServices = sorted(set(NewReceipt.EnabledServices) | set(OldReceipt.EnabledServices))
|
||||
NewReceipt.Save()
|
||||
|
||||
Label = f"{Program.Name} {NewReceipt.Version} ({NewReceipt.Commit[:7]})".replace(" ", " ")
|
||||
Log(f"== {Label} {'updated' if OldReceipt else 'installed'}")
|
||||
return NewReceipt
|
||||
|
||||
|
||||
# --- Remove -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemovalItem:
|
||||
Path: str
|
||||
Group: str # "Program", "Integration", "Config" or "Runtime"
|
||||
Note: str = ""
|
||||
Mode: str = "Remove" # "Remove" or "IfEmpty"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemovalPlan:
|
||||
Items: list[RemovalItem] = field(default_factory=list)
|
||||
|
||||
def Group(self, Name: str) -> list[RemovalItem]:
|
||||
return [Item for Item in self.Items if Item.Group == Name]
|
||||
|
||||
|
||||
def _ExpandLeftover(Item: Leftover, Context: dict) -> list[RemovalItem]:
|
||||
Pattern = str(Path(Render(Item.Path, Context)).expanduser())
|
||||
Paths = sorted(glob(Pattern, include_hidden=True)) if any(Char in Pattern for Char in "*?[") else [Pattern]
|
||||
return [
|
||||
RemovalItem(Found, Item.Kind, Item.Note, Item.Mode)
|
||||
for Found in Paths
|
||||
if Path(Found).exists() or Path(Found).is_symlink()
|
||||
]
|
||||
|
||||
|
||||
def PlanRemoval(Program: Program) -> RemovalPlan:
|
||||
Plan = RemovalPlan()
|
||||
Current = LoadReceipt(Program.Name)
|
||||
Context = Program.Context(Current.Answers if Current else None)
|
||||
|
||||
if Program.ProgramDir.exists():
|
||||
Plan.Items.append(RemovalItem(str(Program.ProgramDir), "Program", "program files and Python venv"))
|
||||
for Downloaded in sorted(DownloadsDir.glob(f"{Program.Name}-*")):
|
||||
Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source"))
|
||||
|
||||
if Current is not None:
|
||||
for Unit in Current.EnabledServices:
|
||||
Plan.Items.append(RemovalItem(Unit, "Integration", "systemd user service, stopped and disabled"))
|
||||
for Placed in Current.Files:
|
||||
if Placed.Kind == "Template":
|
||||
Note = "put back to your previous version" if Placed.Displaced else ""
|
||||
if Placed.Root:
|
||||
Note = ", ".join(Part for Part in ("needs your password", Note) if Part)
|
||||
Plan.Items.append(RemovalItem(Placed.Path, "Integration", Note))
|
||||
elif Path(Placed.Path).exists():
|
||||
Plan.Items.append(RemovalItem(Placed.Path, "Config"))
|
||||
for Record in Current.IniEdits:
|
||||
Note = f"restore {Record.Key}={Record.Previous}" if Record.HadPrevious else f"remove {Record.Key}"
|
||||
Plan.Items.append(RemovalItem(Record.File, "Integration", Note))
|
||||
|
||||
if Program.ConfigDir.exists():
|
||||
Plan.Items.append(RemovalItem(str(Program.ConfigDir), "Config", "the whole config folder"))
|
||||
MergeBackups = BackupsDir / Program.Name / "Configs"
|
||||
if MergeBackups.exists():
|
||||
Plan.Items.append(RemovalItem(str(MergeBackups), "Config", "config backups made during updates"))
|
||||
|
||||
Leftovers = list(Program.Leftovers)
|
||||
if Program.Hooks is not None and hasattr(Program.Hooks, "Leftovers"):
|
||||
Leftovers += Program.Hooks.Leftovers(Context)
|
||||
for Item in Leftovers:
|
||||
Plan.Items.extend(_ExpandLeftover(Item, Context))
|
||||
|
||||
# The same path can come from both the receipt and a Leftover.
|
||||
Seen: set[str] = set()
|
||||
Unique = []
|
||||
for Item in Plan.Items:
|
||||
if Item.Path in Seen:
|
||||
continue
|
||||
Seen.add(Item.Path)
|
||||
Unique.append(Item)
|
||||
Plan.Items = Unique
|
||||
return Plan
|
||||
|
||||
|
||||
def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogFn) -> None:
|
||||
Log(f"== Removing {Program.Name}")
|
||||
Plan = PlanRemoval(Program)
|
||||
Current = LoadReceipt(Program.Name)
|
||||
|
||||
if Current is not None:
|
||||
for Unit in Current.EnabledServices:
|
||||
_Systemctl(Log, "disable", "--now", Unit)
|
||||
for Placed in Current.Files:
|
||||
if Placed.Kind == "Template":
|
||||
_RemovePlacedTemplate(Placed, Log)
|
||||
for Record in Current.IniEdits:
|
||||
_RevertIni(Record, Log)
|
||||
if Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files):
|
||||
_Systemctl(Log, "daemon-reload")
|
||||
|
||||
Groups = {"Program"}
|
||||
if RemoveConfigs:
|
||||
Groups.add("Config")
|
||||
if RemoveRuntime:
|
||||
Groups.add("Runtime")
|
||||
|
||||
# Files first, then "IfEmpty" folders deepest-first, so a folder that only
|
||||
# held things removed above is empty by the time it's checked.
|
||||
Chosen = [Item for Item in Plan.Items if Item.Group in Groups]
|
||||
for Item in [Item for Item in Chosen if Item.Mode == "Remove"]:
|
||||
Target = Path(Item.Path)
|
||||
if Target.exists() or Target.is_symlink():
|
||||
_RemovePath(Target)
|
||||
Log(f"Removed {Target}")
|
||||
for Item in sorted((Item for Item in Chosen if Item.Mode == "IfEmpty"), key=lambda Item: -len(Path(Item.Path).parts)):
|
||||
Target = Path(Item.Path)
|
||||
if Target.is_dir() and not any(Target.iterdir()):
|
||||
Target.rmdir()
|
||||
Log(f"Removed empty {Target}")
|
||||
elif Target.exists():
|
||||
Log(f"Kept {Target} (not empty)")
|
||||
|
||||
# SaraSuite's own bookkeeping. The Pristine configs are kept along with the
|
||||
# user's configs, so a later reinstall can still merge against them.
|
||||
if RemoveConfigs:
|
||||
_RemovePath(PristineDir / Program.Name)
|
||||
_RemovePath(BackupsDir / Program.Name / "Displaced")
|
||||
ProgramBackups = BackupsDir / Program.Name
|
||||
if ProgramBackups.is_dir() and not any(ProgramBackups.iterdir()):
|
||||
ProgramBackups.rmdir()
|
||||
if Current is not None:
|
||||
Current.FilePath.unlink(missing_ok=True)
|
||||
|
||||
_AfterIntegrationChanges(Program, Log)
|
||||
Log(f"== {Program.Name} removed" + ("" if RemoveConfigs else " (configs kept)"))
|
||||
Reference in new issue
Block a user