Dotfiles/desktopenvs/hyprlua/astal-menu/lib/proc.py

101 lines
3.2 KiB
Python

"""Subprocess helpers built on Gio so nothing blocks the GTK main loop.
The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command
prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on
failure. These helpers run such commands asynchronously and hand the parsed result
back on the main thread.
"""
from __future__ import annotations
import json
import shlex
from typing import Callable, Sequence
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
def _as_argv(cmd: Sequence[str] | str) -> list[str]:
return shlex.split(cmd) if isinstance(cmd, str) else list(cmd)
def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None:
"""Run cmd, call cb(ok, stdout, stderr) on the main thread when done."""
try:
proc = Gio.Subprocess.new(
_as_argv(cmd),
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
)
except GLib.Error as err:
GLib.idle_add(cb, False, "", str(err))
return
def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None:
try:
_, out, errout = p.communicate_utf8_finish(res)
except GLib.Error as err:
cb(False, "", str(err))
return
cb(p.get_successful(), out or "", errout or "")
proc.communicate_utf8_async(None, None, _done)
def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None:
"""Run cmd expecting JSON on stdout; call cb(ok, data)."""
def _text(ok: bool, out: str, err: str) -> None:
if not ok or not out.strip():
cb(False, err.strip() or "no output")
return
try:
cb(True, json.loads(out))
except json.JSONDecodeError as exc:
cb(False, f"bad json: {exc}")
run_text(cmd, _text)
class Poller:
"""Repeatedly run a command (or callable) on an interval, main-thread callback.
Used for lightweight state that has no change signal (open ports, public IP,
weather refresh). Modules that back onto an Astal GObject service should prefer
connecting to that service's `notify::` signals instead of polling.
"""
def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False):
self.interval_s = interval_s
self.cmd = cmd
self.cb = cb
self.json_mode = json_mode
self._source_id: int | None = None
self._stopped = False
def start(self) -> "Poller":
self._stopped = False
self._tick()
self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick)
return self
def _tick(self) -> bool:
if self._stopped:
return GLib.SOURCE_REMOVE
if self.json_mode:
run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data))
else:
run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err))
return GLib.SOURCE_CONTINUE
def refresh_now(self) -> None:
self._tick()
def stop(self) -> None:
self._stopped = True
if self._source_id is not None:
GLib.source_remove(self._source_id)
self._source_id = None