# -*- coding: utf-8 -*- """ Asynchronous desktop notification API """ from __future__ import annotations import asyncio import logging import platform import warnings from pathlib import Path from typing import Any, Callable, Sequence, Type, TypeVar from urllib import parse from packaging.version import Version from .backends.base import DesktopNotifierBackend from .common import ( DEFAULT_ICON, DEFAULT_SOUND, Attachment, Button, Capability, Icon, Notification, ReplyField, Sound, Urgency, ) __all__ = [ "Notification", "Button", "ReplyField", "Icon", "Sound", "Attachment", "Urgency", "DesktopNotifier", "Capability", "DEFAULT_SOUND", "DEFAULT_ICON", ] logger = logging.getLogger(__name__) T = TypeVar("T") default_event_loop_policy = asyncio.DefaultEventLoopPolicy() def get_backend_class() -> Type[DesktopNotifierBackend]: """ Return the backend class depending on the platform and version. :returns: A desktop notification backend suitable for the current platform. :raises RuntimeError: when passing ``macos_legacy = True`` on macOS 12.0 and later. """ if platform.system() == "Darwin": from .backends.macos_support import is_bundle, is_signed_bundle, macos_version has_unusernotificationcenter = macos_version >= Version("10.14") if has_unusernotificationcenter and is_bundle(): from .backends.macos import CocoaNotificationCenter if not is_signed_bundle(): logger.warning( "Could not very signature of app bundle, notifications may fail" ) return CocoaNotificationCenter else: if has_unusernotificationcenter: logger.warning( "Notification Center can only be used from an app bundle" ) else: logger.warning("Only macOS 10.14 and later are supported") from .backends.dummy import DummyNotificationCenter return DummyNotificationCenter elif platform.system() == "Linux": from .backends.dbus import DBusDesktopNotifier return DBusDesktopNotifier elif platform.system() == "Windows" and Version(platform.version()) >= Version( "10.0.10240" ): from .backends.winrt import WinRTDesktopNotifier return WinRTDesktopNotifier else: from .backends.dummy import DummyNotificationCenter return DummyNotificationCenter class DesktopNotifier: """Cross-platform desktop notification emitter Uses different backends depending on the platform version and available services. All implementations will dispatch notifications without an event loop but will require a running event loop to execute callbacks when the end user interacts with a notification. On Linux, an asyncio event loop is required. On macOS, a CFRunLoop *in the main thread* is required. Packages such as :mod:`rubicon.objc` can be used to integrate asyncio with a CFRunLoop. Callbacks to handle user interactions with a notification can be specified either at the class level, where they take the notification identifier as input, or directly on the notification itself. The latter will take precedence if set. Note that handlers that are directly set on the notification are tied to Python notification instance and therefore the app's lifecycle. Handlers that are set on the class level may run also for interactions with a notification while the app was not running, if DesktopNotifier is instantiated at app startup. :param app_name: Name to identify the application in the notification center. On Linux, this should correspond to the application name in a desktop entry. On macOS, this argument is ignored and the app is identified by the bundle ID of the sending program (e.g., Python). :param app_icon: Default icon to use for notifications. This should be a :class:`desktop_notifier.base.Icon` instance referencing either a file or a named system icon. :class:`str` or :class:`pathlib.Path` are also accepted but deprecated. """ app_icon: Icon | None def __init__( self, app_name: str = "Python", app_icon: Icon | None = DEFAULT_ICON, notification_limit: int | None = None, ) -> None: if notification_limit is not None: warnings.warn( message="Notification limits have been deprecated and no longer have an effect", category=DeprecationWarning, ) self.app_icon = app_icon backend = get_backend_class() self._backend = backend(app_name) self._did_request_authorisation = False self._capabilities: frozenset[Capability] | None = None @property def app_name(self) -> str: """The application name""" return self._backend.app_name @app_name.setter def app_name(self, value: str) -> None: """Setter: app_name""" self._backend.app_name = value async def request_authorisation(self) -> bool: """ Requests authorisation to send user notifications. This will be automatically called for you when sending a notification for the first time. It also can be called manually to request authorisation in advance. On some platforms such as macOS and iOS, a prompt will be shown to the user when this method is called for the first time. This method does nothing on platforms where user authorisation is not required. :returns: Whether authorisation has been granted. """ self._did_request_authorisation = True return await self._backend.request_authorisation() async def has_authorisation(self) -> bool: """Returns whether we have authorisation to send notifications.""" return await self._backend.has_authorisation() async def send_notification(self, notification: Notification) -> str: """ Sends a desktop notification. This method does not raise an exception when scheduling the notification fails but logs warnings instead. Note that even a successfully scheduled notification may not be displayed to the user, depending on their notification center settings (for instance if "do not disturb" is enabled on macOS). :param notification: The notification to send. :returns: An identifier for the scheduled notification. """ if not notification.icon: object.__setattr__(notification, "icon", self.app_icon) # Ask for authorisation if not already done. On some platforms, this will # trigger a system dialog to ask the user for permission. if not self._did_request_authorisation: await self.request_authorisation() else: logger.debug("Notification center authorisation was already requested") # We attempt to send the notification regardless of authorization. # The user may have changed settings in the meantime. await self._backend.send(notification) return notification.identifier async def send( self, title: str, message: str, urgency: Urgency = Urgency.Normal, icon: Icon | None = None, buttons: Sequence[Button] = (), reply_field: ReplyField | None = None, on_clicked: Callable[[], Any] | None = None, on_dismissed: Callable[[], Any] | None = None, attachment: Attachment | None = None, sound: Sound | None = None, thread: str | None = None, timeout: int = -1, # in seconds ) -> str: """ Sends a desktop notification This is a convenience function which creates a :class:`desktop_notifier.base.Notification` with the provided arguments and then calls :meth:`send_notification`. :returns: An identifier for the scheduled notification. """ notification = Notification( title, message, urgency=urgency, icon=icon, buttons=tuple(buttons), reply_field=reply_field, on_clicked=on_clicked, on_dismissed=on_dismissed, attachment=attachment, sound=sound, thread=thread, timeout=timeout, ) return await self.send_notification(notification) async def get_current_notifications(self) -> list[str]: """Returns identifiers of all currently displayed notifications for this app.""" return await self._backend.get_current_notifications() async def clear(self, identifier: str) -> None: """ Removes the given notification from the notification center. :param identifier: Notification identifier. """ await self._backend.clear(identifier) async def clear_all(self) -> None: """ Removes all currently displayed notifications for this app from the notification center. """ await self._backend.clear_all() async def get_capabilities(self) -> frozenset[Capability]: """ Returns which functionality is supported by the implementation. """ if not self._capabilities: self._capabilities = await self._backend.get_capabilities() return self._capabilities @property def on_clicked(self) -> Callable[[str], Any] | None: """ A method to call when a notification is clicked The method must take the notification identifier as a single argument. If the notification itself already specifies an on_clicked handler, it will be used instead of the class-level handler. """ return self._backend.on_clicked @on_clicked.setter def on_clicked(self, handler: Callable[[str], Any] | None) -> None: self._backend.on_clicked = handler @property def on_dismissed(self) -> Callable[[str], Any] | None: """ A method to call when a notification is dismissed The method must take the notification identifier as a single argument. If the notification itself already specifies an on_dismissed handler, it will be used instead of the class-level handler. """ return self._backend.on_dismissed @on_dismissed.setter def on_dismissed(self, handler: Callable[[str], Any] | None) -> None: self._backend.on_dismissed = handler @property def on_button_pressed(self) -> Callable[[str, str], Any] | None: """ A method to call when a notification is dismissed The method must take the notification identifier and the button number as arguments. If the notification button itself already specifies an on_pressed handler, it will be used instead of the class-level handler. """ return self._backend.on_button_pressed @on_button_pressed.setter def on_button_pressed(self, handler: Callable[[str, str], Any] | None) -> None: self._backend.on_button_pressed = handler @property def on_replied(self) -> Callable[[str, str], Any] | None: """ A method to call when a user responds through the reply field of a notification The method must take the notification identifier and input text as arguments. If the notification's reply field itself already specifies an on_replied handler, it will be used instead of the class-level handler. """ return self._backend.on_replied @on_replied.setter def on_replied(self, handler: Callable[[str, str], Any] | None) -> None: self._backend.on_replied = handler