101 lines
3.2 KiB
Python
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
|