`[tool.ruff]` set a line length and a target version and nothing else, so which rules ran depended on the installed ruff's defaults — fine while nobody ran it, not fine for a CI job that can turn a push red because a tool changed its mind. The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that are each a real decision rather than a shrug: B008, because FastAPI's whole dependency idiom is a call in a default argument; E501, because wrapping prose is a formatter's business; and RUF001, because the reports and the bot speak Russian and "ambiguous character" is the wrong complaint about a string the user is meant to read. The 73 findings that remained are fixed — mostly `datetime.timezone.utc` → `datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order, and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two try/except/pass pairs, unpacking instead of list concatenation, one negated return. `ruff format` is deliberately *not* adopted: it would reformat 51 files, and some of the layout here is hand-set (the comment tables, the DNS wire-format structs) in ways a formatter flattens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""Desktop notifications with buttons, over org.freedesktop.Notifications.
|
|
|
|
Qt's `QSystemTrayIcon.showMessage` can only show text. The real spec — which
|
|
KDE, GNOME and anything using a freedesktop daemon implement — takes *actions*,
|
|
so "Pomodoro finished" can carry [Start break] [Skip] [+5 min] and act without
|
|
you finding the window. Notifications are also replaced in place (the same id),
|
|
so a running timer never stacks up a column of toasts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Callable
|
|
|
|
from PyQt6.QtCore import QMetaType, QObject, QVariant, pyqtSlot
|
|
from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_SERVICE = "org.freedesktop.Notifications"
|
|
_PATH = "/org/freedesktop/Notifications"
|
|
_IFACE = "org.freedesktop.Notifications"
|
|
|
|
# Urgency levels from the freedesktop spec.
|
|
URGENCY_LOW, URGENCY_NORMAL, URGENCY_CRITICAL = 0, 1, 2
|
|
|
|
|
|
class Notifier(QObject):
|
|
"""Sends notifications and routes their button presses back to callables."""
|
|
|
|
def __init__(self, app_name: str = "Kadence", parent: QObject | None = None) -> None:
|
|
super().__init__(parent)
|
|
self._app_name = app_name
|
|
self._bus = QDBusConnection.sessionBus()
|
|
self._interface = QDBusInterface(_SERVICE, _PATH, _IFACE, self._bus)
|
|
self._handlers: dict[int, dict[str, Callable[[], None]]] = {}
|
|
self._last_id = 0
|
|
self._available = self._interface.isValid()
|
|
if self._available:
|
|
self._bus.connect(_SERVICE, _PATH, _IFACE, "ActionInvoked", self._on_action)
|
|
self._bus.connect(_SERVICE, _PATH, _IFACE, "NotificationClosed", self._on_closed)
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
return self._available
|
|
|
|
def capabilities(self) -> list[str]:
|
|
if not self._available:
|
|
return []
|
|
reply = self._interface.call("GetCapabilities")
|
|
arguments = reply.arguments()
|
|
return list(arguments[0]) if arguments else []
|
|
|
|
def notify(
|
|
self,
|
|
summary: str,
|
|
body: str = "",
|
|
*,
|
|
actions: dict[str, tuple[str, Callable[[], None]]] | None = None,
|
|
urgency: int = URGENCY_NORMAL,
|
|
timeout_ms: int = 8000,
|
|
replace: bool = True,
|
|
icon: str = "kadence",
|
|
sound: bool = True,
|
|
) -> int:
|
|
"""Show a notification. `actions` maps key -> (label, callback)."""
|
|
if not self._available:
|
|
return 0
|
|
|
|
action_list: list[str] = []
|
|
handlers: dict[str, Callable[[], None]] = {}
|
|
for key, (label, callback) in (actions or {}).items():
|
|
action_list += [key, label]
|
|
handlers[key] = callback
|
|
|
|
hints = {
|
|
"urgency": QVariant(urgency),
|
|
"desktop-entry": QVariant("kadence"),
|
|
"x-kde-origin-name": QVariant(self._app_name),
|
|
}
|
|
if not sound:
|
|
hints["suppress-sound"] = QVariant(True)
|
|
|
|
# The spec's signature is `susssasa{sv}i`: `replaces_id` is a uint32 and
|
|
# `actions` is an array of strings. Python ints marshal as int32 and a
|
|
# Python list as an array of variants, so both need spelling out or the
|
|
# daemon rejects the call as "no such method".
|
|
reply = self._interface.call(
|
|
"Notify",
|
|
self._app_name,
|
|
QDBusArgument(self._last_id if replace else 0, QMetaType.Type.UInt.value),
|
|
icon,
|
|
summary,
|
|
body,
|
|
QDBusArgument(action_list, QMetaType.Type.QStringList.value),
|
|
hints,
|
|
timeout_ms,
|
|
)
|
|
if reply.errorName():
|
|
log.warning("notification refused: %s — %s", reply.errorName(), reply.errorMessage())
|
|
return 0
|
|
|
|
arguments = reply.arguments()
|
|
try:
|
|
notification_id = int(arguments[0]) if arguments else 0
|
|
except (TypeError, ValueError):
|
|
log.warning("unexpected Notify reply: %r", arguments)
|
|
return 0
|
|
|
|
self._last_id = notification_id
|
|
if handlers:
|
|
self._handlers[notification_id] = handlers
|
|
return notification_id
|
|
|
|
def close(self, notification_id: int) -> None:
|
|
if self._available and notification_id:
|
|
self._interface.call("CloseNotification", notification_id)
|
|
|
|
@pyqtSlot(QDBusMessage)
|
|
def _on_action(self, message: QDBusMessage) -> None:
|
|
arguments = message.arguments()
|
|
if len(arguments) < 2:
|
|
return
|
|
notification_id, key = int(arguments[0]), str(arguments[1])
|
|
handler = self._handlers.get(notification_id, {}).get(key)
|
|
if handler:
|
|
handler()
|
|
|
|
@pyqtSlot(QDBusMessage)
|
|
def _on_closed(self, message: QDBusMessage) -> None:
|
|
arguments = message.arguments()
|
|
if arguments:
|
|
self._handlers.pop(int(arguments[0]), None)
|