Files
SaraSort/Service.py
T
2026-10-09 14:16:50 +02:00

185 lines
7.1 KiB
Python

"""SaraSortd as a background service: status, start/stop/restart, autostart,
and its journal. SaraSortd's own log files are read from its LogDir.
Linux: its systemd user unit (systemctl / journalctl).
Windows: SaraSuite's stand-in for a systemd service, used the same way here:
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py`
from SaraSortd's config folder, and ServiceRun.py records its PID in
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder.
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows.
"""
import re
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from Core import ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig
@dataclass
class ServiceStatus:
Active: str = "unknown" # active, inactive, failed, activating, ...
Sub: str = "" # running, dead, ...
Enabled: str = "unknown" # enabled, disabled, ...
Pid: str = ""
Since: str = ""
Found: bool = False
@property
def Running(self) -> bool:
return self.Active in ("active", "activating", "reloading")
_NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess:
return subprocess.run(Arguments, capture_output=True, text=True, timeout=Timeout, env=ChildEnvironment(),
creationflags=_NoWindow)
# --- Windows ------------------------------------------------------------------------------
def _PidFile(Name: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid"
def _StartupLink(Name: str) -> Path:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
def _WindowsCommand() -> tuple[Path, list[str], Path]:
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout."""
Config = LoadConfig()
AppDir = Path(Config.SortdAppDir).expanduser()
ProgramDir = AppDir.parent
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)]
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser()
def _WindowsPid(Name: str) -> str:
try:
Pid = int(_PidFile(Name).read_text().strip())
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
except (OSError, ValueError, subprocess.SubprocessError):
return ""
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
def _WindowsStatus(Name: str) -> ServiceStatus:
PythonW, Arguments, _Folder = _WindowsCommand()
Pid = _WindowsPid(Name)
Since = ""
if Pid:
Since = datetime.fromtimestamp(_PidFile(Name).stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
return ServiceStatus(
Active="active" if Pid else "inactive",
Sub="running" if Pid else "dead",
Enabled="enabled" if _StartupLink(Name).exists() else "disabled",
Pid=Pid,
Since=Since,
Found=PythonW.is_file() and Path(Arguments[2]).is_file(),
)
def _WindowsControl(Name: str, Action: str) -> str:
PythonW, Arguments, Folder = _WindowsCommand()
try:
if Action in ("stop", "restart"):
Pid = _WindowsPid(Name)
if Pid:
_Run(["taskkill", "/PID", Pid, "/T", "/F"])
_PidFile(Name).unlink(missing_ok=True)
if Action in ("start", "restart"):
if _WindowsPid(Name):
return ""
Folder.mkdir(parents=True, exist_ok=True)
subprocess.Popen(
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
)
elif Action == "enable":
import pylnk3
Link = _StartupLink(Name)
Link.parent.mkdir(parents=True, exist_ok=True)
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(PythonW), str(Link), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
elif Action == "disable":
_StartupLink(Name).unlink(missing_ok=True)
except (OSError, subprocess.SubprocessError) as Error:
return str(Error)
return ""
# --- Both -----------------------------------------------------------------------------------
def Status(Unit: str) -> ServiceStatus:
if IsWindows:
return _WindowsStatus(Unit)
try:
Result = _Run(["systemctl", "--user", "show", Unit,
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
except (OSError, subprocess.SubprocessError):
return ServiceStatus()
Values = dict(Line.split("=", 1) for Line in Result.stdout.splitlines() if "=" in Line)
return ServiceStatus(
Active=Values.get("ActiveState", "unknown"),
Sub=Values.get("SubState", ""),
Enabled=Values.get("UnitFileState", "") or "unknown",
Pid=Values.get("MainPID", "") if Values.get("MainPID") != "0" else "",
Since=Values.get("ActiveEnterTimestamp", ""),
Found=Values.get("LoadState") == "loaded",
)
def Control(Unit: str, Action: str) -> str:
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
if IsWindows:
return _WindowsControl(Unit, Action)
try:
Result = _Run(["systemctl", "--user", Action, Unit])
except (OSError, subprocess.SubprocessError) as Error:
return str(Error)
return "" if Result.returncode == 0 else (Result.stderr.strip() or f"systemctl {Action} failed")
def RestartIfRunning(Unit: str) -> str:
"""For after a GlobalConf.toml change: SaraSortd only reads it at start."""
if not Status(Unit).Running:
return ""
return Control(Unit, "restart")
def Journal(Unit: str, Lines: int) -> list[str]:
if IsWindows:
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) "
"have everything it does."]
try:
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
except (OSError, subprocess.SubprocessError) as Error:
return [f"Couldn't read the journal: {Error}"]
return Result.stdout.splitlines() or [Result.stderr.strip() or "(empty)"]
def LogFiles(LogDir: Path) -> list[Path]:
"""Newest first."""
if not LogDir.is_dir():
return []
return sorted((Item for Item in LogDir.iterdir() if Item.is_file()), key=lambda Item: Item.stat().st_mtime, reverse=True)
def ReadTail(FilePath: Path, Lines: int) -> list[str]:
try:
return FilePath.read_text(encoding="utf-8", errors="replace").splitlines()[-Lines:]
except OSError as Error:
return [f"Couldn't read {FilePath}: {Error}"]