From 98a3279a34f59f238d546086a816725598007b8d Mon Sep 17 00:00:00 2001 From: zwimer Date: Thu, 8 Dec 2022 14:42:07 -0700 Subject: [PATCH 01/20] Implement Listener class --- darkdetect/__init__.py | 61 +++++++++++++------ darkdetect/_dummy.py | 19 +++--- darkdetect/_linux_detect.py | 46 ++++++++++----- darkdetect/_mac_detect.py | 108 ++++++++++++++++++++-------------- darkdetect/_windows_detect.py | 80 ++++++++++++------------- darkdetect/base.py | 86 +++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 126 deletions(-) create mode 100644 darkdetect/base.py diff --git a/darkdetect/__init__.py b/darkdetect/__init__.py index a797685..0551f94 100644 --- a/darkdetect/__init__.py +++ b/darkdetect/__init__.py @@ -4,41 +4,66 @@ # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- -__version__ = '0.7.1' + +__version__: str = '0.7.1' + import sys import platform +from typing import Callable + + +# +# Import correct the listener for the given OS +# + def macos_supported_version(): - sysver = platform.mac_ver()[0] #typically 10.14.2 or 12.3 - major = int(sysver.split('.')[0]) + sysver: str = platform.mac_ver()[0] # typically 10.14.2 or 12.3 + major: int = int(sysver.split('.')[0]) if major < 10: return False elif major >= 11: return True else: - minor = int(sysver.split('.')[1]) + minor: int = int(sysver.split('.')[1]) if minor < 14: return False else: return True -if sys.platform == "darwin": - if macos_supported_version(): - from ._mac_detect import * - else: - from ._dummy import * -elif sys.platform == "win32" and platform.release().isdigit() and int(platform.release()) >= 10: - # Checks if running Windows 10 version 10.0.14393 (Anniversary Update) OR HIGHER. The getwindowsversion method returns a tuple. - # The third item is the build number that we can use to check if the user has a new enough version of Windows. - winver = int(platform.version().split('.')[2]) - if winver >= 14393: - from ._windows_detect import * - else: - from ._dummy import * + +if sys.platform == "darwin" and macos_supported_version(): + from ._mac_detect import * + Listener = MacListener +# If running Windows 10 version 10.0.14393 (Anniversary Update) OR HIGHER. +# The getwindowsversion method returns a tuple. +# The third item is the build number that we can use to check if the user has a new enough version of Windows. +elif sys.platform == "win32" and platform.release().isdigit() and int(platform.release()) >= 10 and \ + int(platform.version().split('.')[2]) >= 14393: + from ._windows_detect import * + Listener = WindowsListener elif sys.platform == "linux": from ._linux_detect import * + Listener = GnomeListener else: from ._dummy import * + Listener = DummyListener + + +# +# Common shortcut functions +# + + +def isDark(): + return theme() == "Dark" + +def isLight(): + return theme() == "Light" + +def listener(callback: Callable[[str], None]) -> None: + Listener(callback).listen() + -del sys, platform +del sys, platform, Callable diff --git a/darkdetect/_dummy.py b/darkdetect/_dummy.py index 1e82117..111b70f 100644 --- a/darkdetect/_dummy.py +++ b/darkdetect/_dummy.py @@ -4,16 +4,17 @@ # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- -import typing +from .base import BaseListener + def theme(): return None - -def isDark(): - return None - -def isLight(): - return None -def listener(callback: typing.Callable[[str], None]) -> None: - raise NotImplementedError() + +class DummyListener(BaseListener): + """ + A dummy listener class that implements nothing the abstract class does not + """ + + +__all__ = ("theme", "DummyListener") diff --git a/darkdetect/_linux_detect.py b/darkdetect/_linux_detect.py index 0570e6a..6f9bd69 100644 --- a/darkdetect/_linux_detect.py +++ b/darkdetect/_linux_detect.py @@ -5,6 +5,9 @@ #----------------------------------------------------------------------------- import subprocess +from typing import Callable, Optional + +from .base import BaseListener def theme(): try: @@ -28,18 +31,31 @@ def theme(): else: return 'Light' -def isDark(): - return theme() == 'Dark' - -def isLight(): - return theme() == 'Light' - -# def listener(callback: typing.Callable[[str], None]) -> None: -def listener(callback): - with subprocess.Popen( - ('gsettings', 'monitor', 'org.gnome.desktop.interface', 'gtk-theme'), - stdout=subprocess.PIPE, - universal_newlines=True, - ) as p: - for line in p.stdout: - callback('Dark' if '-dark' in line.strip().removeprefix("gtk-theme: '").removesuffix("'").lower() else 'Light') + +class GnomeListener(BaseListener): + """ + A listener for Gnome on Linux + """ + + def __init__(self, callback: Callable[[str], None]): + self._proc: Optional[subprocess.Popen] = None + super().__init__(callback) + + def _listen(self): + self._proc = subprocess.Popen( + ('gsettings', 'monitor', 'org.gnome.desktop.interface', 'gtk-theme'), + stdout=subprocess.PIPE, + universal_newlines=True, + ) + with self._proc: + for line in self._proc.stdout: + self.callback('Dark' if '-dark' in line.strip().removeprefix("gtk-theme: '").removesuffix("'").lower() else 'Light') + + def _stop(self): + self._proc.kill() + + def _wait(self): + self._proc.wait() + + +__all__ = ("theme", "GnomeListener",) diff --git a/darkdetect/_mac_detect.py b/darkdetect/_mac_detect.py index 8d44bc7..b4c6194 100644 --- a/darkdetect/_mac_detect.py +++ b/darkdetect/_mac_detect.py @@ -7,10 +7,14 @@ import ctypes import ctypes.util import subprocess +import signal import sys import os from pathlib import Path -from typing import Callable +from typing import Callable, Optional + +from .base import BaseListener + try: from Foundation import NSObject, NSKeyValueObservingOptionNew, NSKeyValueChangeNewKey, NSUserDefaults @@ -76,49 +80,65 @@ def theme(): else: return 'Light' -def isDark(): - return theme() == 'Dark' - -def isLight(): - return theme() == 'Light' - -def _listen_child(): +class MacListener(BaseListener): """ - Run by a child process, install an observer and print theme on change + A listener class for macOS """ - import signal - signal.signal(signal.SIGINT, signal.SIG_IGN) - - OBSERVED_KEY = "AppleInterfaceStyle" - - class Observer(NSObject): - def observeValueForKeyPath_ofObject_change_context_( - self, path, object, changeDescription, context - ): - result = changeDescription[NSKeyValueChangeNewKey] - try: - print(f"{'Light' if result is None else result}", flush=True) - except IOError: - os._exit(1) - - observer = Observer.new() # Keep a reference alive after installing - defaults = NSUserDefaults.standardUserDefaults() - defaults.addObserver_forKeyPath_options_context_( - observer, OBSERVED_KEY, NSKeyValueObservingOptionNew, 0 - ) - - AppHelper.runConsoleEventLoop() - - -def listener(callback: Callable[[str], None]) -> None: - if not _can_listen: - raise NotImplementedError() - with subprocess.Popen( - (sys.executable, "-c", "import _mac_detect as m; m._listen_child()"), - stdout=subprocess.PIPE, - universal_newlines=True, - cwd=Path(__file__).parent, - ) as p: - for line in p.stdout: - callback(line.strip()) + + def __init__(self, callback: Callable[[str], None]): + self._proc: Optional[subprocess.Popen] = None + super().__init__(callback) + + # Overrides + + def _listen(self): + if not _can_listen: + raise NotImplementedError("Optional dependencies not found; fix this with: pip install darkdetect[macos-listener]") + self._proc = subprocess.Popen( + (sys.executable, "-c", "import darkdetect as d; d.MacListener._listen_child()"), + stdout=subprocess.PIPE, + universal_newlines=True, + cwd=Path(__file__).parents[1], + ) + with self._proc: + for line in self._proc.stdout: + self.callback(line.strip()) + + def _stop(self): + self._proc.kill() + + def _wait(self): + self._proc.wait() + + # Internal Methods + + @staticmethod + def _listen_child(): + """ + Run by a child process, install an observer and print theme on change + """ + signal.signal(signal.SIGINT, signal.SIG_IGN) + + OBSERVED_KEY: str = "AppleInterfaceStyle" + + class Observer(NSObject): + def observeValueForKeyPath_ofObject_change_context_( + self, path, object, changeDescription, context + ): + result = changeDescription[NSKeyValueChangeNewKey] + try: + print(f"{'Light' if result is None else result}", flush=True) + except IOError: + os._exit(1) + + observer = Observer.new() # Keep a reference alive after installing + defaults = NSUserDefaults.standardUserDefaults() + defaults.addObserver_forKeyPath_options_context_( + observer, OBSERVED_KEY, NSKeyValueObservingOptionNew, 0 + ) + + AppHelper.runConsoleEventLoop() + + +__all__ = ("theme", "MacListener") diff --git a/darkdetect/_windows_detect.py b/darkdetect/_windows_detect.py index 2363f18..25c2533 100644 --- a/darkdetect/_windows_detect.py +++ b/darkdetect/_windows_detect.py @@ -3,6 +3,8 @@ import ctypes import ctypes.wintypes +from .base import BaseListener + advapi32 = ctypes.windll.advapi32 # LSTATUS RegOpenKeyExA( @@ -55,6 +57,7 @@ ) advapi32.RegNotifyChangeKeyValue.restype = ctypes.wintypes.LONG + def theme(): """ Uses the Windows Registry to detect if the user is using Dark Mode """ # Registry will return 0 if Windows is in Dark Mode and 1 if Windows is in Light Mode. This dictionary converts that output into the text that the program is expecting. @@ -70,53 +73,50 @@ def theme(): return None return valueMeaning[subkey] -def isDark(): - if theme() is not None: - return theme() == 'Dark' - -def isLight(): - if theme() is not None: - return theme() == 'Light' -#def listener(callback: typing.Callable[[str], None]) -> None: -def listener(callback): - hKey = ctypes.wintypes.HKEY() - advapi32.RegOpenKeyExA( - ctypes.wintypes.HKEY(0x80000001), # HKEY_CURRENT_USER - ctypes.wintypes.LPCSTR(b'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize'), - ctypes.wintypes.DWORD(), - ctypes.wintypes.DWORD(0x00020019), # KEY_READ - ctypes.byref(hKey), - ) +class WindowsListener(BaseListener): - dwSize = ctypes.wintypes.DWORD(ctypes.sizeof(ctypes.wintypes.DWORD)) - queryValueLast = ctypes.wintypes.DWORD() - queryValue = ctypes.wintypes.DWORD() - advapi32.RegQueryValueExA( - hKey, - ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), - ctypes.wintypes.LPDWORD(), - ctypes.wintypes.LPDWORD(), - ctypes.cast(ctypes.byref(queryValueLast), ctypes.wintypes.LPBYTE), - ctypes.byref(dwSize), - ) - - while True: - advapi32.RegNotifyChangeKeyValue( - hKey, - ctypes.wintypes.BOOL(True), - ctypes.wintypes.DWORD(0x00000004), # REG_NOTIFY_CHANGE_LAST_SET - ctypes.wintypes.HANDLE(None), - ctypes.wintypes.BOOL(False), + def _listen(self): + hKey = ctypes.wintypes.HKEY() + advapi32.RegOpenKeyExA( + ctypes.wintypes.HKEY(0x80000001), # HKEY_CURRENT_USER + ctypes.wintypes.LPCSTR(b'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize'), + ctypes.wintypes.DWORD(), + ctypes.wintypes.DWORD(0x00020019), # KEY_READ + ctypes.byref(hKey), ) + + dwSize = ctypes.wintypes.DWORD(ctypes.sizeof(ctypes.wintypes.DWORD)) + queryValueLast = ctypes.wintypes.DWORD() + queryValue = ctypes.wintypes.DWORD() advapi32.RegQueryValueExA( hKey, ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), ctypes.wintypes.LPDWORD(), ctypes.wintypes.LPDWORD(), - ctypes.cast(ctypes.byref(queryValue), ctypes.wintypes.LPBYTE), + ctypes.cast(ctypes.byref(queryValueLast), ctypes.wintypes.LPBYTE), ctypes.byref(dwSize), ) - if queryValueLast.value != queryValue.value: - queryValueLast.value = queryValue.value - callback('Light' if queryValue.value else 'Dark') + + while True: + advapi32.RegNotifyChangeKeyValue( + hKey, + ctypes.wintypes.BOOL(True), + ctypes.wintypes.DWORD(0x00000004), # REG_NOTIFY_CHANGE_LAST_SET + ctypes.wintypes.HANDLE(None), + ctypes.wintypes.BOOL(False), + ) + advapi32.RegQueryValueExA( + hKey, + ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), + ctypes.wintypes.LPDWORD(), + ctypes.wintypes.LPDWORD(), + ctypes.cast(ctypes.byref(queryValue), ctypes.wintypes.LPBYTE), + ctypes.byref(dwSize), + ) + if queryValueLast.value != queryValue.value: + queryValueLast.value = queryValue.value + self.callback('Light' if queryValue.value else 'Dark') + + +__all__ = ("theme", "WindowsListener",) diff --git a/darkdetect/base.py b/darkdetect/base.py new file mode 100644 index 0000000..d527bc7 --- /dev/null +++ b/darkdetect/base.py @@ -0,0 +1,86 @@ +from typing import Callable +from enum import Enum, auto + + +class ListenerState(Enum): + """ + A listener state + """ + Listening = auto() + Stopping = auto() + Dead = auto() + + +class BaseListener: + """ + An abstract listener class + Subclasses promise that it is safe to call stop() then wait() + from a different thread than listen() was called in; provides two + threads are not both racing to call these methods + """ + + def __init__(self, callback: Callable[[str], None]): + """ + :param callback: The callback to use when the listener detects something + """ + self._state: ListenerState = ListenerState.Dead + self.callback: Callable[[str], None] = callback + + def listen(self): + """ + Start the listener if it is not already running + """ + if self._state == ListenerState.Listening: + raise RuntimeError("Do not run .listen() from multiple threads concurrently") + elif self._state == ListenerState.Stopping: + raise RuntimeError("Call .wait() to wait for the previous listener to finish shutting down") + self._state = ListenerState.Listening + try: + self._listen() + except Exception as e: + self.stop() # Just in case + raise RuntimeError("Listen failed") from e + + def stop(self): + """ + Tells the listener to stop; may return before the listener has stopped + If the listener is not currently listening, this is a no-op + This function may be called if .listen() errors + """ + if self._state == ListenerState.Listening: + self._stop() + self._state = ListenerState.Stopping + + def wait(self): + """ + Stop the listener and wait's for it to finish + If the listener is dead, this is a no-op + """ + if self._state != ListenerState.Dead: + self.stop() + self._wait() + self._state = ListenerState.Dead + + # Non-public methods + + def _listen(self): + """ + Start the listener + """ + raise NotImplementedError() + + def _stop(self): + """ + Tell the listener, do not bother waiting for it to finish stopping + """ + raise NotImplementedError() + + def _wait(self): + """ + Wait for the listener to stop + Promised that .stop() method will have already been called + """ + raise NotImplementedError() + + +__all__ = ("BaseListener", "ListenerState") From 928faa468e4bc3e1cad9a0b9622147a4ab7ea3a8 Mon Sep 17 00:00:00 2001 From: zwimer Date: Thu, 8 Dec 2022 15:10:39 -0700 Subject: [PATCH 02/20] Code quality improvement --- darkdetect/__init__.py | 19 ++++++------------- darkdetect/__main__.py | 2 +- darkdetect/_linux_detect.py | 22 +++++++--------------- darkdetect/_mac_detect.py | 11 ++++------- darkdetect/base.py | 2 +- darkdetect/py.typed | 1 + pyproject.toml | 3 +++ 7 files changed, 23 insertions(+), 37 deletions(-) create mode 100644 darkdetect/py.typed diff --git a/darkdetect/__init__.py b/darkdetect/__init__.py index 0551f94..c5fbefd 100644 --- a/darkdetect/__init__.py +++ b/darkdetect/__init__.py @@ -10,28 +10,23 @@ import sys import platform -from typing import Callable +from typing import Callable, Type +from .base import BaseListener +Listener: Type[BaseListener] # # Import correct the listener for the given OS # - def macos_supported_version(): sysver: str = platform.mac_ver()[0] # typically 10.14.2 or 12.3 major: int = int(sysver.split('.')[0]) if major < 10: return False - elif major >= 11: + if major >= 11: return True - else: - minor: int = int(sysver.split('.')[1]) - if minor < 14: - return False - else: - return True - + return int(sysver.split('.')[1]) >= 14 if sys.platform == "darwin" and macos_supported_version(): from ._mac_detect import * @@ -50,12 +45,10 @@ def macos_supported_version(): from ._dummy import * Listener = DummyListener - # # Common shortcut functions # - def isDark(): return theme() == "Dark" @@ -66,4 +59,4 @@ def listener(callback: Callable[[str], None]) -> None: Listener(callback).listen() -del sys, platform, Callable +del sys, platform, Callable, Type diff --git a/darkdetect/__main__.py b/darkdetect/__main__.py index 1cb260b..3cb063e 100644 --- a/darkdetect/__main__.py +++ b/darkdetect/__main__.py @@ -6,4 +6,4 @@ import darkdetect -print('Current theme: {}'.format(darkdetect.theme())) +print(f"Current theme: {darkdetect.theme()}") diff --git a/darkdetect/_linux_detect.py b/darkdetect/_linux_detect.py index 6f9bd69..ae4eadf 100644 --- a/darkdetect/_linux_detect.py +++ b/darkdetect/_linux_detect.py @@ -9,27 +9,19 @@ from .base import BaseListener + def theme(): try: #Using the freedesktop specifications for checking dark mode - out = subprocess.run( - ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], - capture_output=True) - stdout = out.stdout.decode() + stdout = subprocess.check_output(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme']) #If not found then trying older gtk-theme method if len(stdout)<1: - out = subprocess.run( - ['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], - capture_output=True) - stdout = out.stdout.decode() - except Exception: - return 'Light' + stdout = subprocess.check_output(['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme']) + except subprocess.SubprocessError: + return "Light" # we have a string, now remove start and end quote - theme = stdout.lower().strip()[1:-1] - if '-dark' in theme.lower(): - return 'Dark' - else: - return 'Light' + theme_: bytes = stdout.lower().strip()[1:-1] + return "Dark" if b"-dark" in theme_.lower() else "Light" class GnomeListener(BaseListener): diff --git a/darkdetect/_mac_detect.py b/darkdetect/_mac_detect.py index b4c6194..6f66d14 100644 --- a/darkdetect/_mac_detect.py +++ b/darkdetect/_mac_detect.py @@ -59,8 +59,8 @@ def theme(): pool = msg(NSAutoreleasePool, n('alloc')) pool = msg(pool, n('init')) - NSUserDefaults = C('NSUserDefaults') - stdUserDef = msg(NSUserDefaults, n('standardUserDefaults')) + NSUserDefaults_ = C('NSUserDefaults') + stdUserDef = msg(NSUserDefaults_, n('standardUserDefaults')) NSString = C('NSString') @@ -75,10 +75,7 @@ def theme(): msg(pool, n('release')) - if out is not None: - return out.decode('utf-8') - else: - return 'Light' + return "Light" if out is None else out.decode('utf-8') class MacListener(BaseListener): @@ -124,7 +121,7 @@ def _listen_child(): class Observer(NSObject): def observeValueForKeyPath_ofObject_change_context_( - self, path, object, changeDescription, context + self, path, object_, changeDescription, context ): result = changeDescription[NSKeyValueChangeNewKey] try: diff --git a/darkdetect/base.py b/darkdetect/base.py index d527bc7..aa4bc03 100644 --- a/darkdetect/base.py +++ b/darkdetect/base.py @@ -32,7 +32,7 @@ def listen(self): """ if self._state == ListenerState.Listening: raise RuntimeError("Do not run .listen() from multiple threads concurrently") - elif self._state == ListenerState.Stopping: + if self._state == ListenerState.Stopping: raise RuntimeError("Call .wait() to wait for the previous listener to finish shutting down") self._state = ListenerState.Listening try: diff --git a/darkdetect/py.typed b/darkdetect/py.typed new file mode 100644 index 0000000..99d7bf6 --- /dev/null +++ b/darkdetect/py.typed @@ -0,0 +1 @@ +PARTIAL diff --git a/pyproject.toml b/pyproject.toml index d1b6ebc..1f8f245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ macos-listener = [ "pyobjc-framework-Cocoa; platform_system == 'Darwin'" ] [tool.setuptools] include-package-data = true +[tool.setuptools.package_data] +darkdetect = ["py.typed"] + [tool.setuptools.dynamic.version] attr = "darkdetect.__version__" From 27502cf1c17709ecb1a9475db24babdb951eeae0 Mon Sep 17 00:00:00 2001 From: zwimer Date: Thu, 8 Dec 2022 15:30:17 -0700 Subject: [PATCH 03/20] Typo --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1f8f245..53bffda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ macos-listener = [ "pyobjc-framework-Cocoa; platform_system == 'Darwin'" ] [tool.setuptools] include-package-data = true -[tool.setuptools.package_data] +[tool.setuptools.package-data] darkdetect = ["py.typed"] [tool.setuptools.dynamic.version] From 1d84c8476666947d67119a52065532c0be60f020 Mon Sep 17 00:00:00 2001 From: zwimer Date: Thu, 8 Dec 2022 15:58:50 -0700 Subject: [PATCH 04/20] Allow NotImplementedErrors to propogate in _mac_detect --- darkdetect/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/darkdetect/base.py b/darkdetect/base.py index aa4bc03..c07a67a 100644 --- a/darkdetect/base.py +++ b/darkdetect/base.py @@ -37,6 +37,9 @@ def listen(self): self._state = ListenerState.Listening try: self._listen() + except NotImplementedError: + self._state = ListenerState.Dead + raise except Exception as e: self.stop() # Just in case raise RuntimeError("Listen failed") from e From ff935d1d6628c903e3de7cbaf9048e3f36ee25a1 Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 12 Dec 2022 17:11:38 -0700 Subject: [PATCH 05/20] Updates requested in PR + timeout added to wait --- README.md | 2 +- darkdetect/__init__.py | 28 +++++++--- darkdetect/{base.py => _base_listener.py} | 20 ++++--- darkdetect/_dummy.py | 2 +- darkdetect/_linux_detect.py | 18 ++++--- darkdetect/_mac_detect.py | 18 ++++--- darkdetect/_windows_detect.py | 65 +++++++++++++++-------- 7 files changed, 100 insertions(+), 53 deletions(-) rename darkdetect/{base.py => _base_listener.py} (81%) diff --git a/README.md b/README.md index 4e0d32c..b75ec16 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ pip install darkdetect[macos-listener] ## Notes - This software is licensed under the terms of the 3-clause BSD License. -- This package can be installed on any operative system, but it will always return `None` unless executed on a OS that supports Dark Mode, including older versions of macOS and Windows. +- This package can be installed on any operative system, but `theme()`, `isDark()`, and `isLight()` will always return `None` unless executed on a OS that supports Dark Mode, including older versions of macOS and Windows. - On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. - [Details](https://stackoverflow.com/questions/25207077/how-to-detect-if-os-x-is-in-dark-mode) on the detection method used on macOS. - [Details](https://askubuntu.com/questions/1261366/detecting-dark-mode#comment2132694_1261366) on the experimental detection method used on Linux. diff --git a/darkdetect/__init__.py b/darkdetect/__init__.py index c5fbefd..4e7cf64 100644 --- a/darkdetect/__init__.py +++ b/darkdetect/__init__.py @@ -10,9 +10,9 @@ import sys import platform -from typing import Callable, Type +from typing import Callable, Optional, Type -from .base import BaseListener +from ._base_listener import BaseListener, DDTimeoutError Listener: Type[BaseListener] # @@ -32,8 +32,6 @@ def macos_supported_version(): from ._mac_detect import * Listener = MacListener # If running Windows 10 version 10.0.14393 (Anniversary Update) OR HIGHER. -# The getwindowsversion method returns a tuple. -# The third item is the build number that we can use to check if the user has a new enough version of Windows. elif sys.platform == "win32" and platform.release().isdigit() and int(platform.release()) >= 10 and \ int(platform.version().split('.')[2]) >= 14393: from ._windows_detect import * @@ -49,13 +47,27 @@ def macos_supported_version(): # Common shortcut functions # -def isDark(): - return theme() == "Dark" +def isDark() -> Optional[bool]: + """ + :return: True if the theme is Dark, False if not, None if there is no support for this OS + """ + t: Optional[str] = theme() + return t if t is None else (t == "Dark") + + +def isLight() -> Optional[bool]: + """ + :return: True if the theme is Light, False if not, None if there is no support for this OS + """ + t: Optional[str] = theme() + return t if t is None else (t == "Light") -def isLight(): - return theme() == "Light" def listener(callback: Callable[[str], None]) -> None: + """ + Listen for a theme change, on theme change, invoke callback(theme_name) + :param callback: The callback to invoke + """ Listener(callback).listen() diff --git a/darkdetect/base.py b/darkdetect/_base_listener.py similarity index 81% rename from darkdetect/base.py rename to darkdetect/_base_listener.py index c07a67a..99f9b2d 100644 --- a/darkdetect/base.py +++ b/darkdetect/_base_listener.py @@ -1,4 +1,4 @@ -from typing import Callable +from typing import Callable, Optional from enum import Enum, auto @@ -11,6 +11,12 @@ class ListenerState(Enum): Dead = auto() +class DDTimeoutError(RuntimeError): + """ + Raised when a listener's .wait() call times out + """ + + class BaseListener: """ An abstract listener class @@ -54,14 +60,16 @@ def stop(self): self._stop() self._state = ListenerState.Stopping - def wait(self): + def wait(self, timeout: Optional[int] = None): """ - Stop the listener and wait's for it to finish + Stop the listener and waits for it to finish If the listener is dead, this is a no-op + If this function times out, a DDTimeoutError will be raised + :param timeout: Ensure this function will wait at max timeout seconds if specified """ if self._state != ListenerState.Dead: self.stop() - self._wait() + self._wait(timeout) self._state = ListenerState.Dead # Non-public methods @@ -78,7 +86,7 @@ def _stop(self): """ raise NotImplementedError() - def _wait(self): + def _wait(self, timeout: Optional[int]): """ Wait for the listener to stop Promised that .stop() method will have already been called @@ -86,4 +94,4 @@ def _wait(self): raise NotImplementedError() -__all__ = ("BaseListener", "ListenerState") +__all__ = ("BaseListener", "ListenerState", "DDTimeoutError") diff --git a/darkdetect/_dummy.py b/darkdetect/_dummy.py index 111b70f..530a4be 100644 --- a/darkdetect/_dummy.py +++ b/darkdetect/_dummy.py @@ -4,7 +4,7 @@ # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- -from .base import BaseListener +from ._base_listener import BaseListener def theme(): diff --git a/darkdetect/_linux_detect.py b/darkdetect/_linux_detect.py index ae4eadf..3b8b1f1 100644 --- a/darkdetect/_linux_detect.py +++ b/darkdetect/_linux_detect.py @@ -7,10 +7,10 @@ import subprocess from typing import Callable, Optional -from .base import BaseListener +from ._base_listener import BaseListener, DDTimeoutError -def theme(): +def theme() -> Optional[str]: try: #Using the freedesktop specifications for checking dark mode stdout = subprocess.check_output(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme']) @@ -30,24 +30,26 @@ class GnomeListener(BaseListener): """ def __init__(self, callback: Callable[[str], None]): - self._proc: Optional[subprocess.Popen] = None + self._proc: subprocess.Popen super().__init__(callback) def _listen(self): - self._proc = subprocess.Popen( + with subprocess.Popen( ('gsettings', 'monitor', 'org.gnome.desktop.interface', 'gtk-theme'), stdout=subprocess.PIPE, universal_newlines=True, - ) - with self._proc: + ) as self._proc: for line in self._proc.stdout: self.callback('Dark' if '-dark' in line.strip().removeprefix("gtk-theme: '").removesuffix("'").lower() else 'Light') def _stop(self): self._proc.kill() - def _wait(self): - self._proc.wait() + def _wait(self, timeout: Optional[int]): + try: + self._proc.wait(timeout) + except subprocess.TimeoutExpired as e: + raise DDTimeoutError from e __all__ = ("theme", "GnomeListener",) diff --git a/darkdetect/_mac_detect.py b/darkdetect/_mac_detect.py index 6f66d14..9b6071e 100644 --- a/darkdetect/_mac_detect.py +++ b/darkdetect/_mac_detect.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Callable, Optional -from .base import BaseListener +from ._base_listener import BaseListener, DDTimeoutError try: @@ -54,7 +54,7 @@ def n(name): def C(classname): return objc.objc_getClass(_utf8(classname)) -def theme(): +def theme() -> Optional[str]: NSAutoreleasePool = objc.objc_getClass('NSAutoreleasePool') pool = msg(NSAutoreleasePool, n('alloc')) pool = msg(pool, n('init')) @@ -84,7 +84,7 @@ class MacListener(BaseListener): """ def __init__(self, callback: Callable[[str], None]): - self._proc: Optional[subprocess.Popen] = None + self._proc: subprocess.Popen super().__init__(callback) # Overrides @@ -92,21 +92,23 @@ def __init__(self, callback: Callable[[str], None]): def _listen(self): if not _can_listen: raise NotImplementedError("Optional dependencies not found; fix this with: pip install darkdetect[macos-listener]") - self._proc = subprocess.Popen( + with subprocess.Popen( (sys.executable, "-c", "import darkdetect as d; d.MacListener._listen_child()"), stdout=subprocess.PIPE, universal_newlines=True, cwd=Path(__file__).parents[1], - ) - with self._proc: + ) as self._proc: for line in self._proc.stdout: self.callback(line.strip()) def _stop(self): self._proc.kill() - def _wait(self): - self._proc.wait() + def _wait(self, timeout: Optional[int]): + try: + self._proc.wait(timeout) + except subprocess.TimeoutExpired as e: + raise DDTimeoutError from e # Internal Methods diff --git a/darkdetect/_windows_detect.py b/darkdetect/_windows_detect.py index 25c2533..e6f4195 100644 --- a/darkdetect/_windows_detect.py +++ b/darkdetect/_windows_detect.py @@ -1,9 +1,11 @@ from winreg import HKEY_CURRENT_USER as hkey, QueryValueEx as getSubkeyValue, OpenKey as getKey +import threading import ctypes import ctypes.wintypes +from typing import Callable, Optional -from .base import BaseListener +from ._base_listener import BaseListener, ListenerState, DDTimeoutError advapi32 = ctypes.windll.advapi32 @@ -58,7 +60,7 @@ advapi32.RegNotifyChangeKeyValue.restype = ctypes.wintypes.LONG -def theme(): +def theme() -> Optional[str]: """ Uses the Windows Registry to detect if the user is using Dark Mode """ # Registry will return 0 if Windows is in Dark Mode and 1 if Windows is in Light Mode. This dictionary converts that output into the text that the program is expecting. valueMeaning = {0: "Dark", 1: "Light"} @@ -75,6 +77,13 @@ def theme(): class WindowsListener(BaseListener): + """ + A listener class for Windows + """ + + def __init__(self, callback: Callable[[str], None]): + self._lock: threading.Lock + super().__init__(callback) def _listen(self): hKey = ctypes.wintypes.HKEY() @@ -98,25 +107,39 @@ def _listen(self): ctypes.byref(dwSize), ) - while True: - advapi32.RegNotifyChangeKeyValue( - hKey, - ctypes.wintypes.BOOL(True), - ctypes.wintypes.DWORD(0x00000004), # REG_NOTIFY_CHANGE_LAST_SET - ctypes.wintypes.HANDLE(None), - ctypes.wintypes.BOOL(False), - ) - advapi32.RegQueryValueExA( - hKey, - ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), - ctypes.wintypes.LPDWORD(), - ctypes.wintypes.LPDWORD(), - ctypes.cast(ctypes.byref(queryValue), ctypes.wintypes.LPBYTE), - ctypes.byref(dwSize), - ) - if queryValueLast.value != queryValue.value: - queryValueLast.value = queryValue.value - self.callback('Light' if queryValue.value else 'Dark') + self._lock = threading.Lock() + with self._lock: + while self._state == ListenerState.Listening: + advapi32.RegNotifyChangeKeyValue( + hKey, + ctypes.wintypes.BOOL(True), + ctypes.wintypes.DWORD(0x00000004), # REG_NOTIFY_CHANGE_LAST_SET + ctypes.wintypes.HANDLE(None), + ctypes.wintypes.BOOL(False), + ) + advapi32.RegQueryValueExA( + hKey, + ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), + ctypes.wintypes.LPDWORD(), + ctypes.wintypes.LPDWORD(), + ctypes.cast(ctypes.byref(queryValue), ctypes.wintypes.LPBYTE), + ctypes.byref(dwSize), + ) + if queryValueLast.value != queryValue.value: + queryValueLast.value = queryValue.value + self.callback('Light' if queryValue.value else 'Dark') + + def _stop(self): + pass # Override NotSupported; stop() will set the ListenerState which is what we need + # TODO: Also interrupt the listener rather than permit it to die + + def _wait(self, timeout: Optional[int]): + try: + if not self._lock.acquire(timeout=(-1 if timeout is None else timeout)): + raise DDTimeoutError(f"Timed out after {timeout} seconds.") + except Exception: + self._lock.release() + raise __all__ = ("theme", "WindowsListener",) From 43695688b930f37c559c4f5c32c581c9bb73b56e Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 12 Dec 2022 18:05:46 -0700 Subject: [PATCH 06/20] README.md update --- README.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b75ec16..d26a332 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,25 @@ This package allows to detect if the user is using Dark Mode on: The main application of this package is to detect the Dark mode from your GUI Python application (Tkinter/wx/pyqt/qt for python (pyside)/...) and apply the needed adjustments to your interface. Darkdetect is particularly useful if your GUI library **does not** provide a public API for this detection (I am looking at you, Qt). In addition, this package does not depend on other modules or packages that are not already included in standard Python distributions. +## Install -## Usage +The preferred channel is PyPI: +```bash +pip install darkdetect +``` +Alternatively, you are free to vendor directly a copy of Darkdetect in your app. Further information on vendoring can be found [here](https://medium.com/underdog-io-engineering/vendoring-python-dependencies-with-pip-b9eb6078b9c0). + +### macOS Listener Support + +To enable the macOS listener, additional components are required, these can be installed via: +```bash +pip install darkdetect[macos-listener] ``` + +## Usage + +```python import darkdetect >>> darkdetect.theme() @@ -25,35 +40,104 @@ False ``` It's that easy. -You can create a dark mode switch listener daemon thread with `darkdetect.listener` and pass a callback function. The function will be called with string "Dark" or "Light" when the OS switches the dark mode setting. +### Listener + +`darkdetect` exposes a listener API which is far more efficient than busy waiting on `theme()` changes. +This API is exposed primarily via a `Listener` class. +The `darkdetect.Listener` class exposes the following methods / members: + +##### `.__init__(callback: Callable[[str], None])` + +The construct simply sets `.callback` to the given callback argument + +##### `.callback: Callable[[str], None]` + +The callback function that the listener uses. +The function will be called with string "Dark" or "Light" when the OS +It is safe to change this during program execution. + +##### `.listen()` + +This starts listening for theme changes, it will invoke `self.callback(theme_name)` when a change is detected. + +##### `.stop()` + +This initiates the listener stop procedure; it will return immediately and will not wait for the listener or running callbacks to complete; it simply informs the listener that it may stop listening. +Internally, listening may be done via a subprocess, so this can be thought of as a `subprocess.kill`. +If the listener is not actively listening, this function is a no-op. -``` python +##### `.wait(timeout: Optional[int] = None)` + +This will stop (as needed) the listener and wait for it / running callbacks to complete execution. +It is not necessary to invoke `.stop()` before this function, as `.wait()` will invoke `.stop()` automatically. +If a timeout is specified, `.wait` will wait at most `timeout` seconds before exiting. +If this method times out, it will raise a `darkdetect.DDTimeoutError`. + +##### Wrapper Function + +The simplest method of using this API is the `darkdetect.listener` function, which takes a callback function as an argument. +This function is a small wrapper around `Listener(callback).listen()`. +_In this mode, the listener cannot be stopped_; forceful stops may not clean up resources (such as subprocesses if applicable). + + +### Examples + +##### A simple listener: +```python import threading import darkdetect -# def listener(callback: typing.Callable[[str], None]) -> None: ... - -t = threading.Thread(target=darkdetect.listener, args=(print,)) -t.daemon = True +listener = darkdetect.Listener(print) +t = threading.Thread(target=listener.listen, daemon=True) t.start() ``` -*On macOS it simply raises a NotImplementedError. PRs in this direction are more than welcome.* +##### User input controlling listener +```python +import threading +import darkdetect +import time -## Install +listener = darkdetect.Listener(print) +t = threading.Thread(target=listener.listen) +t.start() -The preferred channel is PyPI: -``` -pip install darkdetect +txt = "" +while txt != "quit": + txt = input() + if txt == "print": + listener.callback = print + elif txt == "verbose": + listener.callback = lambda x: print(f"The theme changed to {x} as {time.time()}") +listener.stop() + +print("Waiting for running callbacks to complete") +listener.wait() ``` -Alternatively, you are free to vendor directly a copy of Darkdetect in your app. Further information on vendoring can be found [here](https://medium.com/underdog-io-engineering/vendoring-python-dependencies-with-pip-b9eb6078b9c0). +##### Possible GUI app shutdown sequence +```python +... -## Optional Installs +def shutdown(): + listener.stop() # Initiate stop, allows callbacks to continue running + other_shutdown_methods() # Stop other processes -To enable the macOS listener, additional components are required, these can be installed via: -```bash -pip install darkdetect[macos-listener] + # Wait a bit longer for callbacks to complete and listener to clean up + try: + listener.wait(timeout = 10) # This app has long callbacks, shutdown should be fast though! + except DarkDetect.DDTimeoutError as e: + # Log that callbacks are still running but we are quitting anyway + logger.exception(e) +``` + +##### Super simple example of wrapper `listener` function: +```python +import threading +import darkdetect + +t = threading.Thread(target=darkdetect.listener, args=(print,), daemon=True) +t.start() ``` ## Notes From 42ca972853a51460189a8b87ec349ffed98dd452 Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 12 Dec 2022 18:08:55 -0700 Subject: [PATCH 07/20] Typos --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d26a332..2b8f5ae 100644 --- a/README.md +++ b/README.md @@ -117,18 +117,16 @@ listener.wait() ##### Possible GUI app shutdown sequence ```python -... - -def shutdown(): - listener.stop() # Initiate stop, allows callbacks to continue running - other_shutdown_methods() # Stop other processes +def shutdown(self): + self.listener.stop() # Initiate stop, allows callbacks to continue running + self.other_shutdown_methods() # Stop other processes # Wait a bit longer for callbacks to complete and listener to clean up try: - listener.wait(timeout = 10) # This app has long callbacks, shutdown should be fast though! - except DarkDetect.DDTimeoutError as e: - # Log that callbacks are still running but we are quitting anyway - logger.exception(e) + self.listener.wait(timeout = 10) # This app has long callbacks, shutdown should be fast though! + except darkdetect.DDTimeoutError as e: + # Log that callbacks are still running but that we are quitting anyway + self.logger.exception(e) ``` ##### Super simple example of wrapper `listener` function: From b236af2ba08863e487e62c0e06a42197e9273a7d Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 12 Dec 2022 18:36:03 -0700 Subject: [PATCH 08/20] Bug fix + prevent extra callbacks after stopped on windows --- darkdetect/_windows_detect.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/darkdetect/_windows_detect.py b/darkdetect/_windows_detect.py index e6f4195..55be2c4 100644 --- a/darkdetect/_windows_detect.py +++ b/darkdetect/_windows_detect.py @@ -127,7 +127,14 @@ def _listen(self): ) if queryValueLast.value != queryValue.value: queryValueLast.value = queryValue.value - self.callback('Light' if queryValue.value else 'Dark') + self._callback('Light' if queryValue.value else 'Dark') + + def _callback(self, theme: str): + """ + A small wrapper around callback, ensures future callbacks will not be made + """ + if self._state == ListenerState.Listening: + self.callback(theme) def _stop(self): pass # Override NotSupported; stop() will set the ListenerState which is what we need @@ -135,11 +142,12 @@ def _stop(self): def _wait(self, timeout: Optional[int]): try: - if not self._lock.acquire(timeout=(-1 if timeout is None else timeout)): - raise DDTimeoutError(f"Timed out after {timeout} seconds.") + timed_out: bool = not self._lock.acquire(timeout=(-1 if timeout is None else timeout)) except Exception: self._lock.release() raise + if timed_out: + raise DDTimeoutError(f"Timed out after {timeout} seconds.") __all__ = ("theme", "WindowsListener",) From c86fe018bb84d9bbd70092cdf9337bdc359d20f3 Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 12 Dec 2022 18:40:35 -0700 Subject: [PATCH 09/20] Simplify code --- darkdetect/_windows_detect.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/darkdetect/_windows_detect.py b/darkdetect/_windows_detect.py index 55be2c4..5042309 100644 --- a/darkdetect/_windows_detect.py +++ b/darkdetect/_windows_detect.py @@ -127,14 +127,8 @@ def _listen(self): ) if queryValueLast.value != queryValue.value: queryValueLast.value = queryValue.value - self._callback('Light' if queryValue.value else 'Dark') - - def _callback(self, theme: str): - """ - A small wrapper around callback, ensures future callbacks will not be made - """ - if self._state == ListenerState.Listening: - self.callback(theme) + if self._state == ListenerState.Listening: + self.callback('Light' if queryValue.value else 'Dark') def _stop(self): pass # Override NotSupported; stop() will set the ListenerState which is what we need From da419f30887d4a4e0d361dd2c3cd634e81d7b388 Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 12 Dec 2022 18:59:39 -0700 Subject: [PATCH 10/20] README notes update about windows listener --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2b8f5ae..024daff 100644 --- a/README.md +++ b/README.md @@ -143,5 +143,6 @@ t.start() - This software is licensed under the terms of the 3-clause BSD License. - This package can be installed on any operative system, but `theme()`, `isDark()`, and `isLight()` will always return `None` unless executed on a OS that supports Dark Mode, including older versions of macOS and Windows. - On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. +- On Windows, the after `Listener.stop()` is invoked and running callbacks complete, future callbacks should not be made, but the listener itself will not die until another theme change; that is `.wait()` will hang until another theme change. _PRs fixing this are welcome._ - [Details](https://stackoverflow.com/questions/25207077/how-to-detect-if-os-x-is-in-dark-mode) on the detection method used on macOS. - [Details](https://askubuntu.com/questions/1261366/detecting-dark-mode#comment2132694_1261366) on the experimental detection method used on Linux. From c66fa8a4d36d1ff0165634fa31a51041ddd13ad5 Mon Sep 17 00:00:00 2001 From: zwimer Date: Fri, 16 Dec 2022 12:50:26 -0700 Subject: [PATCH 11/20] Replace wait and stop with singular stop function --- README.md | 69 ++++++++++++++++++++++------------ darkdetect/__init__.py | 9 ++++- darkdetect/_base_listener.py | 70 ++++++++++++++++++----------------- darkdetect/_linux_detect.py | 22 +++++------ darkdetect/_mac_detect.py | 25 +++++-------- darkdetect/_windows_detect.py | 24 ++++-------- 6 files changed, 118 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 024daff..42526ea 100644 --- a/README.md +++ b/README.md @@ -43,35 +43,53 @@ It's that easy. ### Listener `darkdetect` exposes a listener API which is far more efficient than busy waiting on `theme()` changes. -This API is exposed primarily via a `Listener` class. +This API is exposed primarily via a `Listener` class. The `darkdetect.Listener` class exposes the following methods / members: -##### `.__init__(callback: Callable[[str], None])` +##### `.__init__(callback: Optional[Callable[[str], None]])` The construct simply sets `.callback` to the given callback argument -##### `.callback: Callable[[str], None]` +##### `.callback: Optional[Callable[[str], None]]` The callback function that the listener uses. -The function will be called with string "Dark" or "Light" when the OS +The function will be called with string "Dark" or "Light" when the OS It is safe to change this during program execution. +It is safe to set this value to `None`, while the listener will still be active, +theme changes will not invoke the callback; though running callbacks will not be interrupted. +This is useful if 'temporarily pausing' the listener is desired. ##### `.listen()` -This starts listening for theme changes, it will invoke `self.callback(theme_name)` when a change is detected. +This starts listening for theme changes, it will invoke +`self.callback(theme_name)` when a change is detected. -##### `.stop()` +After a listener is stopped successfully via `.stop` (the return value must be `True`), +it can be started again via `.listen()`. +New listeners may be constructed, should waiting for `.stop` not be desired. -This initiates the listener stop procedure; it will return immediately and will not wait for the listener or running callbacks to complete; it simply informs the listener that it may stop listening. -Internally, listening may be done via a subprocess, so this can be thought of as a `subprocess.kill`. -If the listener is not actively listening, this function is a no-op. +##### `.stop(timeout: Optional[int]) -> bool` -##### `.wait(timeout: Optional[int] = None)` +This function initiates the listener stop sequence and +waits for the listener to stop for at most `timeout` seconds. +This function returns `True` if the listener successfully +stops before the timeout expires; otherwise `False`. +`timeout` may be any non-negative integer or `None`. +After `.stop` returns, regardless of the argument passed to it, +theme changes will no longer invoke the callback +Running callbacks will not be interrupted and may continue executing. -This will stop (as needed) the listener and wait for it / running callbacks to complete execution. -It is not necessary to invoke `.stop()` before this function, as `.wait()` will invoke `.stop()` automatically. -If a timeout is specified, `.wait` will wait at most `timeout` seconds before exiting. -If this method times out, it will raise a `darkdetect.DDTimeoutError`. +`.stop` may safely be re-invoked any number of times. +Calling `.stop(None)` after `.stop(0)` will work as expected. + +In most cases `.stop(0)` should be sufficient as this will successfully +prevent future theme changes from generating callbacks. +The two primary use cases for `stop` with a timeout are: +1. Cleaning up listener resources (be that subprocesses or something else) +2. To restart the existing listener; a listener's `.listen()` function +may only be re-invoked if a call to `.stop` has returned `True`. + +Warning: `stop(None)` may hang if the listener cannot be interrupted until another theme change is detected. ##### Wrapper Function @@ -109,24 +127,23 @@ while txt != "quit": listener.callback = print elif txt == "verbose": listener.callback = lambda x: print(f"The theme changed to {x} as {time.time()}") -listener.stop() +listener.stop(0) -print("Waiting for running callbacks to complete") -listener.wait() +print("Waiting for running callbacks to complete and the listener to terminate") +listener.stop(None) ``` ##### Possible GUI app shutdown sequence ```python def shutdown(self): - self.listener.stop() # Initiate stop, allows callbacks to continue running + self.listener.stop(0) # Initiate stop, allows callbacks to continue running self.other_shutdown_methods() # Stop other processes # Wait a bit longer for callbacks to complete and listener to clean up try: - self.listener.wait(timeout = 10) # This app has long callbacks, shutdown should be fast though! - except darkdetect.DDTimeoutError as e: - # Log that callbacks are still running but that we are quitting anyway - self.logger.exception(e) + if self.listener.stop(timeout=10) is False: + # Log that listener is still running but that we are quitting anyway + self.logger.exception("Failed to shutdown listener within 10 seconds, quitting anyway.") ``` ##### Super simple example of wrapper `listener` function: @@ -138,11 +155,15 @@ t = threading.Thread(target=darkdetect.listener, args=(print,), daemon=True) t.start() ``` +## Known Issues + +1. On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. +1. On macOS, using the listener API in a bundled app where `sys.executable` is not a python interpreter (such as pyinstaller builds), is not supported. +1. On Windows, the after `Listener.stop` is invoked and running callbacks complete, future callbacks should not be made, but the listener itself will not die until another theme change; that is `.wait()` will hang until another theme change. + ## Notes - This software is licensed under the terms of the 3-clause BSD License. - This package can be installed on any operative system, but `theme()`, `isDark()`, and `isLight()` will always return `None` unless executed on a OS that supports Dark Mode, including older versions of macOS and Windows. -- On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. -- On Windows, the after `Listener.stop()` is invoked and running callbacks complete, future callbacks should not be made, but the listener itself will not die until another theme change; that is `.wait()` will hang until another theme change. _PRs fixing this are welcome._ - [Details](https://stackoverflow.com/questions/25207077/how-to-detect-if-os-x-is-in-dark-mode) on the detection method used on macOS. - [Details](https://askubuntu.com/questions/1261366/detecting-dark-mode#comment2132694_1261366) on the experimental detection method used on Linux. diff --git a/darkdetect/__init__.py b/darkdetect/__init__.py index 4e7cf64..6a72508 100644 --- a/darkdetect/__init__.py +++ b/darkdetect/__init__.py @@ -12,7 +12,7 @@ import platform from typing import Callable, Optional, Type -from ._base_listener import BaseListener, DDTimeoutError +from ._base_listener import BaseListener Listener: Type[BaseListener] # @@ -68,7 +68,12 @@ def listener(callback: Callable[[str], None]) -> None: Listen for a theme change, on theme change, invoke callback(theme_name) :param callback: The callback to invoke """ - Listener(callback).listen() + l = Listener(callback) + try: + l.listen() + except KeyboardInterrupt: + l.stop(0) + raise del sys, platform, Callable, Type diff --git a/darkdetect/_base_listener.py b/darkdetect/_base_listener.py index 99f9b2d..8e50668 100644 --- a/darkdetect/_base_listener.py +++ b/darkdetect/_base_listener.py @@ -11,26 +11,19 @@ class ListenerState(Enum): Dead = auto() -class DDTimeoutError(RuntimeError): - """ - Raised when a listener's .wait() call times out - """ - - class BaseListener: """ An abstract listener class - Subclasses promise that it is safe to call stop() then wait() - from a different thread than listen() was called in; provides two - threads are not both racing to call these methods + It is safe to call stop from a different thread than listen() was called in + provided multiple threads are not racing to call these methods """ - def __init__(self, callback: Callable[[str], None]): + def __init__(self, callback: Optional[Callable[[str], None]]): """ :param callback: The callback to use when the listener detects something """ self._state: ListenerState = ListenerState.Dead - self.callback: Callable[[str], None] = callback + self.callback: Optional[Callable[[str], None]] = callback def listen(self): """ @@ -39,7 +32,7 @@ def listen(self): if self._state == ListenerState.Listening: raise RuntimeError("Do not run .listen() from multiple threads concurrently") if self._state == ListenerState.Stopping: - raise RuntimeError("Call .wait() to wait for the previous listener to finish shutting down") + raise RuntimeError("Call .stop to wait for the previous listener to finish shutting down") self._state = ListenerState.Listening try: self._listen() @@ -50,48 +43,59 @@ def listen(self): self.stop() # Just in case raise RuntimeError("Listen failed") from e - def stop(self): + def stop(self, timeout: Optional[int] = None) -> bool: """ - Tells the listener to stop; may return before the listener has stopped - If the listener is not currently listening, this is a no-op - This function may be called if .listen() errors + Initiate the listener stop sequence, wait at most timeout seconds for it to complete. + After this function returns, new theme changes will not invoke callbacks. + Running callbacks will not be interrupted. + May safely be called as many times as desired. + Warning: stop(None) may hang until the next theme change, depending on implementation + :param timeout: How many seconds to wait until the listener stops; None means infinite + :return: True if the listener completes before the timeout expires, else False """ + if timeout is not None and timeout < 0: + raise RuntimeError("timeout may not be negative") if self._state == ListenerState.Listening: - self._stop() + self._initiate_shutdown() self._state = ListenerState.Stopping + if self._state == ListenerState.Stopping: + if self._wait_for_shutdown(timeout): + self._state = ListenerState.Dead + return self._state == ListenerState.Dead + + # Non-public helper methods - def wait(self, timeout: Optional[int] = None): + def _invoke_callback(self, value: str) -> None: """ - Stop the listener and waits for it to finish - If the listener is dead, this is a no-op - If this function times out, a DDTimeoutError will be raised - :param timeout: Ensure this function will wait at max timeout seconds if specified + Invoke the stored callback if the state is listening """ - if self._state != ListenerState.Dead: - self.stop() - self._wait(timeout) - self._state = ListenerState.Dead + if self._state == ListenerState.Listening: + c: Optional[Callable[[str], None]] = self.callback + if c is not None: + c(value) # Non-public methods - def _listen(self): + def _listen(self) -> None: """ Start the listener """ raise NotImplementedError() - def _stop(self): + def _initiate_shutdown(self) -> None: """ - Tell the listener, do not bother waiting for it to finish stopping + Tell the listener to initiate shutdown """ raise NotImplementedError() - def _wait(self, timeout: Optional[int]): + def _wait_for_shutdown(self, timeout: Optional[int]) -> bool: """ - Wait for the listener to stop - Promised that .stop() method will have already been called + Wait for the listener to stop at most timeout seconds + Promised that _initiate_shutdown() will have already run + :param timeout: How many seconds to wait until the listener stops; None means infinite + :return: True if the listener completes before the timeout expires, else False """ raise NotImplementedError() -__all__ = ("BaseListener", "ListenerState", "DDTimeoutError") +__all__ = ("BaseListener", "ListenerState") diff --git a/darkdetect/_linux_detect.py b/darkdetect/_linux_detect.py index 3b8b1f1..e02a9e6 100644 --- a/darkdetect/_linux_detect.py +++ b/darkdetect/_linux_detect.py @@ -7,7 +7,7 @@ import subprocess from typing import Callable, Optional -from ._base_listener import BaseListener, DDTimeoutError +from ._base_listener import BaseListener def theme() -> Optional[str]: @@ -29,27 +29,27 @@ class GnomeListener(BaseListener): A listener for Gnome on Linux """ - def __init__(self, callback: Callable[[str], None]): - self._proc: subprocess.Popen - super().__init__(callback) - - def _listen(self): + def _listen(self) -> None: with subprocess.Popen( ('gsettings', 'monitor', 'org.gnome.desktop.interface', 'gtk-theme'), stdout=subprocess.PIPE, universal_newlines=True, ) as self._proc: for line in self._proc.stdout: - self.callback('Dark' if '-dark' in line.strip().removeprefix("gtk-theme: '").removesuffix("'").lower() else 'Light') + self._invoke_callback( + 'Dark' if '-dark' in line.strip().removeprefix("gtk-theme: '").removesuffix("'").lower() + else 'Light' + ) - def _stop(self): + def _initiate_shutdown(self) -> None: self._proc.kill() - def _wait(self, timeout: Optional[int]): + def _wait_for_shutdown(self, timeout: Optional[int]) -> bool: try: self._proc.wait(timeout) - except subprocess.TimeoutExpired as e: - raise DDTimeoutError from e + return True + except subprocess.TimeoutExpired: + return False __all__ = ("theme", "GnomeListener",) diff --git a/darkdetect/_mac_detect.py b/darkdetect/_mac_detect.py index 9b6071e..71c5a44 100644 --- a/darkdetect/_mac_detect.py +++ b/darkdetect/_mac_detect.py @@ -11,9 +11,9 @@ import sys import os from pathlib import Path -from typing import Callable, Optional +from typing import Optional -from ._base_listener import BaseListener, DDTimeoutError +from ._base_listener import BaseListener try: @@ -83,13 +83,7 @@ class MacListener(BaseListener): A listener class for macOS """ - def __init__(self, callback: Callable[[str], None]): - self._proc: subprocess.Popen - super().__init__(callback) - - # Overrides - - def _listen(self): + def _listen(self) -> None: if not _can_listen: raise NotImplementedError("Optional dependencies not found; fix this with: pip install darkdetect[macos-listener]") with subprocess.Popen( @@ -99,21 +93,22 @@ def _listen(self): cwd=Path(__file__).parents[1], ) as self._proc: for line in self._proc.stdout: - self.callback(line.strip()) + self._invoke_callback(line.strip()) - def _stop(self): + def _initiate_shutdown(self) -> None: self._proc.kill() - def _wait(self, timeout: Optional[int]): + def _wait_for_shutdown(self, timeout: Optional[int]) -> bool: try: self._proc.wait(timeout) - except subprocess.TimeoutExpired as e: - raise DDTimeoutError from e + return True + except subprocess.TimeoutExpired: + return False # Internal Methods @staticmethod - def _listen_child(): + def _listen_child() -> None: """ Run by a child process, install an observer and print theme on change """ diff --git a/darkdetect/_windows_detect.py b/darkdetect/_windows_detect.py index 5042309..b2efa80 100644 --- a/darkdetect/_windows_detect.py +++ b/darkdetect/_windows_detect.py @@ -3,9 +3,9 @@ import threading import ctypes import ctypes.wintypes -from typing import Callable, Optional +from typing import Optional -from ._base_listener import BaseListener, ListenerState, DDTimeoutError +from ._base_listener import BaseListener, ListenerState advapi32 = ctypes.windll.advapi32 @@ -81,11 +81,7 @@ class WindowsListener(BaseListener): A listener class for Windows """ - def __init__(self, callback: Callable[[str], None]): - self._lock: threading.Lock - super().__init__(callback) - - def _listen(self): + def _listen(self) -> None: hKey = ctypes.wintypes.HKEY() advapi32.RegOpenKeyExA( ctypes.wintypes.HKEY(0x80000001), # HKEY_CURRENT_USER @@ -127,21 +123,17 @@ def _listen(self): ) if queryValueLast.value != queryValue.value: queryValueLast.value = queryValue.value - if self._state == ListenerState.Listening: - self.callback('Light' if queryValue.value else 'Dark') + self._invoke_callback('Light' if queryValue.value else 'Dark') - def _stop(self): - pass # Override NotSupported; stop() will set the ListenerState which is what we need - # TODO: Also interrupt the listener rather than permit it to die + def _initiate_shutdown(self) -> None: + pass # TODO: Interrupt the listener rather than permit it to die - def _wait(self, timeout: Optional[int]): + def _wait_for_shutdown(self, timeout: Optional[int]) -> bool: try: - timed_out: bool = not self._lock.acquire(timeout=(-1 if timeout is None else timeout)) + return self._lock.acquire(timeout=(-1 if timeout is None else timeout)) except Exception: self._lock.release() raise - if timed_out: - raise DDTimeoutError(f"Timed out after {timeout} seconds.") __all__ = ("theme", "WindowsListener",) From c653fe85f62235734b9ab94b0cb897ab740d4eda Mon Sep 17 00:00:00 2001 From: zwimer Date: Fri, 16 Dec 2022 17:14:00 -0700 Subject: [PATCH 12/20] Allow subclasses to handle listener errors --- darkdetect/_base_listener.py | 32 +++++++++++++++++++++++++------- darkdetect/_linux_detect.py | 2 +- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/darkdetect/_base_listener.py b/darkdetect/_base_listener.py index 3b1400d..8909eb2 100644 --- a/darkdetect/_base_listener.py +++ b/darkdetect/_base_listener.py @@ -36,12 +36,8 @@ def listen(self): self._state = ListenerState.Listening try: self._listen() - except NotImplementedError: - self._state = ListenerState.Dead - raise - except Exception as e: - self.stop() # Just in case - raise RuntimeError("Listen failed") from e + except BaseException as e: + self._on_listen_fail_base(e) def stop(self, timeout: Optional[int] = None) -> bool: """ @@ -74,28 +70,50 @@ def _invoke_callback(self, value: str) -> None: if c is not None: c(value) + def _on_listen_fail_base(self, why: BaseException) -> None: + """ + Invoked by .listen on all failures; self._state is unknown + Note that .stop may still be called on a failed listener! + Users should only override this if catching base exceptions is required + For example: Emergency cleanup when a user KeyboardInterrupts a program via Ctrl-C + :param why: The exception caught by _listen + """ + if isinstance(why, (BaseException, NotImplementedError)): + raise why + self._on_listen_fail(why) + # Non-public methods def _listen(self) -> None: """ Start the listener + Will only be called if self._state is Dead """ raise NotImplementedError() def _initiate_shutdown(self) -> None: """ Tell the listener to initiate shutdown + Will only be called if self._state is Listening """ raise NotImplementedError() def _wait_for_shutdown(self, timeout: Optional[int]) -> bool: """ Wait for the listener to stop at most timeout seconds - Promised that _initiate_shutdown() will have already run + Will only be called if self._state is Stopping :param timeout: How many seconds to wait until the listener stops; None means infinite :return: True if the listener completes before the timeout expires, else False """ raise NotImplementedError() + def _on_listen_fail(self, why: Exception) -> None: + """ + Invoked by .listen on most failures; self._state is unknown + Note that .stop may still be called on a failed listener! + :param why: The exception caught by _listen + """ + raise RuntimeError("Listener.listen failed") from why + __all__ = ("BaseListener", "ListenerState") diff --git a/darkdetect/_linux_detect.py b/darkdetect/_linux_detect.py index e02a9e6..e5f3621 100644 --- a/darkdetect/_linux_detect.py +++ b/darkdetect/_linux_detect.py @@ -5,7 +5,7 @@ #----------------------------------------------------------------------------- import subprocess -from typing import Callable, Optional +from typing import Optional from ._base_listener import BaseListener From 6875fc1e3479acdd0d19e379ab237ffca68ebc31 Mon Sep 17 00:00:00 2001 From: zwimer Date: Fri, 16 Dec 2022 17:20:12 -0700 Subject: [PATCH 13/20] Remove default argument for timeout in stop --- darkdetect/_base_listener.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/darkdetect/_base_listener.py b/darkdetect/_base_listener.py index 8909eb2..b54179e 100644 --- a/darkdetect/_base_listener.py +++ b/darkdetect/_base_listener.py @@ -39,7 +39,7 @@ def listen(self): except BaseException as e: self._on_listen_fail_base(e) - def stop(self, timeout: Optional[int] = None) -> bool: + def stop(self, timeout: Optional[int]) -> bool: """ Initiate the listener stop sequence, wait at most timeout seconds for it to complete. After this function returns, new theme changes will not invoke callbacks. From 04fadf987c6d1fb8743641393b4acb7ae2315de9 Mon Sep 17 00:00:00 2001 From: zwimer Date: Fri, 16 Dec 2022 17:30:51 -0700 Subject: [PATCH 14/20] Documentation --- README.md | 8 ++++---- darkdetect/_base_listener.py | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 42526ea..ec34d6d 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,6 @@ The two primary use cases for `stop` with a timeout are: 2. To restart the existing listener; a listener's `.listen()` function may only be re-invoked if a call to `.stop` has returned `True`. -Warning: `stop(None)` may hang if the listener cannot be interrupted until another theme change is detected. - ##### Wrapper Function The simplest method of using this API is the `darkdetect.listener` function, which takes a callback function as an argument. @@ -130,7 +128,8 @@ while txt != "quit": listener.stop(0) print("Waiting for running callbacks to complete and the listener to terminate") -listener.stop(None) +if not listener.stop(timeout=5): + print("Callbacks / listener are still running after 5 seconds!") ``` ##### Possible GUI app shutdown sequence @@ -159,7 +158,8 @@ t.start() 1. On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. 1. On macOS, using the listener API in a bundled app where `sys.executable` is not a python interpreter (such as pyinstaller builds), is not supported. -1. On Windows, the after `Listener.stop` is invoked and running callbacks complete, future callbacks should not be made, but the listener itself will not die until another theme change; that is `.wait()` will hang until another theme change. +1. On Windows, the after `Listener.stop(None)` is not supported as it may not die until another theme change is detected. +Future invcations of `callback` will not be made, but the listener itself will persist. ## Notes diff --git a/darkdetect/_base_listener.py b/darkdetect/_base_listener.py index b54179e..47fdd89 100644 --- a/darkdetect/_base_listener.py +++ b/darkdetect/_base_listener.py @@ -45,7 +45,6 @@ def stop(self, timeout: Optional[int]) -> bool: After this function returns, new theme changes will not invoke callbacks. Running callbacks will not be interrupted. May safely be called as many times as desired. - Warning: stop(None) may hang until the next theme change, depending on implementation :param timeout: How many seconds to wait until the listener stops; None means infinite :return: True if the listener completes before the timeout expires, else False """ From 585630b1c0ac7d5a47401ff28d64680d4fbd631c Mon Sep 17 00:00:00 2001 From: zwimer Date: Wed, 21 Dec 2022 19:51:49 -0700 Subject: [PATCH 15/20] Fix typos in readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ec34d6d..20523c5 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,12 @@ The `darkdetect.Listener` class exposes the following methods / members: ##### `.__init__(callback: Optional[Callable[[str], None]])` -The construct simply sets `.callback` to the given callback argument +The constructor simply sets `.callback` to the given callback argument ##### `.callback: Optional[Callable[[str], None]]` The callback function that the listener uses. -The function will be called with string "Dark" or "Light" when the OS +This function will be passed "Dark" or "Light" when the theme is changed. It is safe to change this during program execution. It is safe to set this value to `None`, while the listener will still be active, theme changes will not invoke the callback; though running callbacks will not be interrupted. @@ -145,7 +145,7 @@ def shutdown(self): self.logger.exception("Failed to shutdown listener within 10 seconds, quitting anyway.") ``` -##### Super simple example of wrapper `listener` function: +##### Example of wrapper `listener` function: ```python import threading import darkdetect @@ -159,7 +159,7 @@ t.start() 1. On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. 1. On macOS, using the listener API in a bundled app where `sys.executable` is not a python interpreter (such as pyinstaller builds), is not supported. 1. On Windows, the after `Listener.stop(None)` is not supported as it may not die until another theme change is detected. -Future invcations of `callback` will not be made, but the listener itself will persist. +Future invocations of `callback` will not be made, but the listener itself will persist. ## Notes From 5accc519414ed2312b8f115031c6f233d41fff0b Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 9 Jan 2023 14:59:13 -0700 Subject: [PATCH 16/20] docs + cleanup --- README.md | 72 +++++++++--------------------------- darkdetect/_base_listener.py | 22 ++++------- docs/api.md | 55 +++++++++++++++++++++++++++ docs/examples.md | 71 +++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 70 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/examples.md diff --git a/README.md b/README.md index 20523c5..3ebcec4 100644 --- a/README.md +++ b/README.md @@ -44,60 +44,43 @@ It's that easy. `darkdetect` exposes a listener API which is far more efficient than busy waiting on `theme()` changes. This API is exposed primarily via a `Listener` class. -The `darkdetect.Listener` class exposes the following methods / members: +Detailed API documentation can be found [here](docs/api.md). +For a quick overview: the `darkdetect.Listener` class exposes the following methods / members: ##### `.__init__(callback: Optional[Callable[[str], None]])` - The constructor simply sets `.callback` to the given callback argument ##### `.callback: Optional[Callable[[str], None]]` - -The callback function that the listener uses. -This function will be passed "Dark" or "Light" when the theme is changed. -It is safe to change this during program execution. -It is safe to set this value to `None`, while the listener will still be active, -theme changes will not invoke the callback; though running callbacks will not be interrupted. -This is useful if 'temporarily pausing' the listener is desired. +The settable callback function that the listener uses; it will be passed "Dark" or "Light" when the theme is changed. ##### `.listen()` - This starts listening for theme changes, it will invoke `self.callback(theme_name)` when a change is detected. -After a listener is stopped successfully via `.stop` (the return value must be `True`), -it can be started again via `.listen()`. -New listeners may be constructed, should waiting for `.stop` not be desired. - ##### `.stop(timeout: Optional[int]) -> bool` -This function initiates the listener stop sequence and -waits for the listener to stop for at most `timeout` seconds. -This function returns `True` if the listener successfully -stops before the timeout expires; otherwise `False`. -`timeout` may be any non-negative integer or `None`. -After `.stop` returns, regardless of the argument passed to it, -theme changes will no longer invoke the callback -Running callbacks will not be interrupted and may continue executing. +This function attempts to stop the listener, +waiting at most `timeout` seconds (`None` means infinite), +returning `True` on success, `False` on timeout. -`.stop` may safely be re-invoked any number of times. -Calling `.stop(None)` after `.stop(0)` will work as expected. +Regardless of the result, after `.stop` returns, theme changes +will no longer trigger `callback`, though running callbacks will +not be interrupted. -In most cases `.stop(0)` should be sufficient as this will successfully -prevent future theme changes from generating callbacks. -The two primary use cases for `stop` with a timeout are: -1. Cleaning up listener resources (be that subprocesses or something else) -2. To restart the existing listener; a listener's `.listen()` function -may only be re-invoked if a call to `.stop` has returned `True`. +`.stop` may safely be re-invoked any number of times. +`.listen()` may not be called until a call to `.stop` succeeds. ##### Wrapper Function -The simplest method of using this API is the `darkdetect.listener` function, which takes a callback function as an argument. +The simplest method of using this API is the `darkdetect.listener` function, +which takes a callback function as an argument. This function is a small wrapper around `Listener(callback).listen()`. _In this mode, the listener cannot be stopped_; forceful stops may not clean up resources (such as subprocesses if applicable). - ### Examples +Below are 2 examples of basic usage; additional examples can be found [here](docs/examples.md). + ##### A simple listener: ```python import threading @@ -105,6 +88,7 @@ import darkdetect listener = darkdetect.Listener(print) t = threading.Thread(target=listener.listen, daemon=True) +# OR: t = threading.Thread(target=darkdetect.listener, args=(print,), daemon=True) t.start() ``` @@ -129,29 +113,7 @@ listener.stop(0) print("Waiting for running callbacks to complete and the listener to terminate") if not listener.stop(timeout=5): - print("Callbacks / listener are still running after 5 seconds!") -``` - -##### Possible GUI app shutdown sequence -```python -def shutdown(self): - self.listener.stop(0) # Initiate stop, allows callbacks to continue running - self.other_shutdown_methods() # Stop other processes - - # Wait a bit longer for callbacks to complete and listener to clean up - try: - if self.listener.stop(timeout=10) is False: - # Log that listener is still running but that we are quitting anyway - self.logger.exception("Failed to shutdown listener within 10 seconds, quitting anyway.") -``` - -##### Example of wrapper `listener` function: -```python -import threading -import darkdetect - -t = threading.Thread(target=darkdetect.listener, args=(print,), daemon=True) -t.start() + print("Callbacks/listener are still running after 5 seconds!") ``` ## Known Issues diff --git a/darkdetect/_base_listener.py b/darkdetect/_base_listener.py index 47fdd89..a72667f 100644 --- a/darkdetect/_base_listener.py +++ b/darkdetect/_base_listener.py @@ -37,7 +37,7 @@ def listen(self): try: self._listen() except BaseException as e: - self._on_listen_fail_base(e) + self._on_listen_fail(e) def stop(self, timeout: Optional[int]) -> bool: """ @@ -69,18 +69,6 @@ def _invoke_callback(self, value: str) -> None: if c is not None: c(value) - def _on_listen_fail_base(self, why: BaseException) -> None: - """ - Invoked by .listen on all failures; self._state is unknown - Note that .stop may still be called on a failed listener! - Users should only override this if catching base exceptions is required - For example: Emergency cleanup when a user KeyboardInterrupts a program via Ctrl-C - :param why: The exception caught by _listen - """ - if isinstance(why, (BaseException, NotImplementedError)): - raise why - self._on_listen_fail(why) - # Non-public methods def _listen(self) -> None: @@ -106,12 +94,16 @@ def _wait_for_shutdown(self, timeout: Optional[int]) -> bool: """ raise NotImplementedError() - def _on_listen_fail(self, why: Exception) -> None: + def _on_listen_fail(self, why: BaseException) -> None: """ - Invoked by .listen on most failures; self._state is unknown + Invoked by .listen on all failures; self._state is unknown Note that .stop may still be called on a failed listener! + This function must handle BaseExceptions as well! + For example: Emergency cleanup when a user KeyboardInterrupts a program via Ctrl-C :param why: The exception caught by _listen """ + if isinstance(why, (BaseException, NotImplementedError)): + raise why raise RuntimeError("Listener.listen failed") from why diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..7773169 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,55 @@ +# `darkdetect` API + +The centerpiece of the API is the `Listener` class. +Members of this class are: + +### The constructor: `.__init__(callback: Optional[Callable[[str], None]])` + +The constructor simply sets `.callback` to the given callback argument + +### The callback: `.callback: Optional[Callable[[str], None]]` + +The callback function that the listener uses. +This function will be passed "Dark" or "Light" when the theme is changed. +It is safe to change this during program execution. +It is safe to set this value to `None`, while the listener will still be active, +theme changes will not invoke the callback; though running callbacks will not be interrupted. +This is useful if 'temporarily pausing' the listener is desired. + +### The listen function: `.listen()` + +This starts listening for theme changes, it will invoke +`self.callback(theme_name)` when a change is detected. + +After a listener is stopped successfully via `.stop` (the return value must be `True`), +it can be started again via `.listen()`. +New listeners may be constructed, should waiting for `.stop` not be desired. + +### The stop function: `.stop(timeout: Optional[int]) -> bool` + +This function initiates the listener stop sequence and +waits for the listener to stop for at most `timeout` seconds. +This function returns `True` if the listener successfully +stops before the timeout expires; otherwise `False`. +`timeout` may be any non-negative integer or `None`. +After `.stop` returns, regardless of the argument passed to it, +theme changes will no longer invoke the callback +Running callbacks will not be interrupted and may continue executing. + +`.stop` may safely be re-invoked any number of times. +Calling `.stop(None)` after `.stop(0)` will work as expected. + +In most cases `.stop(0)` should be sufficient as this will successfully +prevent future theme changes from generating callbacks. +The two primary use cases for `stop` with a timeout are: +1. Cleaning up listener resources (be that subprocesses or something else) +2. To restart the existing listener; a listener's `.listen()` function + may only be re-invoked if a call to `.stop` has returned `True`. + +--- + +## Wrapper Function + +The simplest method of using this API is the `darkdetect.listener` function, which takes a callback function as an argument. +This function is a small wrapper around `Listener(callback).listen()`. +_In this mode, the listener cannot be stopped_; forceful stops may not clean up resources (such as subprocesses if applicable). diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..1510d58 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,71 @@ +# `darkdetect` Examples + +### Listening with the `listener` method +```python +import threading +import darkdetect + +t = threading.Thread(target=darkdetect.listener, args=(print,), daemon=True) +t.start() +``` + +### Listening with the `Listener` class +```python +import threading +import darkdetect + +listener = darkdetect.Listener(print) +t = threading.Thread(target=listener.listen, daemon=True) +t.start() +``` + +### Stop on user input +```python +import threading +import darkdetect + +listener = darkdetect.Listener(print) +t = threading.Thread(target=listener.listen, daemon=True) +t.start() + +input() # Wait for user input +listener.stop(0) +``` + +### User adjustable callback +```python +import threading +import darkdetect +import time + +listener = darkdetect.Listener(print) +t = threading.Thread(target=listener.listen) +t.start() + +txt = "" +while txt != "quit": + txt = input() + if txt == "print": + listener.callback = print + elif txt == "verbose": + listener.callback = lambda x: print(f"The theme changed to {x} as {time.time()}") +listener.stop(0) + +print("Waiting for running callbacks to complete and the listener to terminate") +if not listener.stop(timeout=5): + print("Callbacks/listener are still running after 5 seconds!") +``` + +### Possible GUI app shutdown sequence +```python +def shutdown(self): + self.listener.stop(0) # Prevent callback from being invoked on theme changes + # Existing callbacks may still be running! + + self.other_shutdown_methods() # Stop other processes + + # Wait a bit longer for callbacks to complete and listener to clean up + if self.listener.stop(timeout=5) is False: + # Log that listener is still running but that we are quitting anyway + self.logger.exception("Failed to shutdown listener / running callbacks within 5 seconds, quitting anyway.") +``` From 8624bfeb381a32ad0bfc7b75366d991385f2f8bd Mon Sep 17 00:00:00 2001 From: zwimer Date: Mon, 9 Jan 2023 15:01:27 -0700 Subject: [PATCH 17/20] Condense --- README.md | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3ebcec4..c8a25b7 100644 --- a/README.md +++ b/README.md @@ -47,28 +47,18 @@ This API is exposed primarily via a `Listener` class. Detailed API documentation can be found [here](docs/api.md). For a quick overview: the `darkdetect.Listener` class exposes the following methods / members: -##### `.__init__(callback: Optional[Callable[[str], None]])` -The constructor simply sets `.callback` to the given callback argument - -##### `.callback: Optional[Callable[[str], None]]` -The settable callback function that the listener uses; it will be passed "Dark" or "Light" when the theme is changed. - -##### `.listen()` -This starts listening for theme changes, it will invoke +1. `.__init__(callback: Optional[Callable[[str], None]])`: The constructor simply sets `.callback` to the given callback argument +1. `.callback: Optional[Callable[[str], None]]`: The settable callback function that the listener uses; it will be passed "Dark" or "Light" when the theme is changed. +1. `.listen()`: This starts listening for theme changes, it will invoke `self.callback(theme_name)` when a change is detected. - -##### `.stop(timeout: Optional[int]) -> bool` - +1. `.stop(timeout: Optional[int]) -> bool`: This function attempts to stop the listener, waiting at most `timeout` seconds (`None` means infinite), returning `True` on success, `False` on timeout. - Regardless of the result, after `.stop` returns, theme changes will no longer trigger `callback`, though running callbacks will not be interrupted. - -`.stop` may safely be re-invoked any number of times. -`.listen()` may not be called until a call to `.stop` succeeds. +`.stop` may safely be re-invoked any number of times, but must succeed at before re-calling `.listen()`. ##### Wrapper Function From 137d910b023df8afe34d48a1985d29fd7f90e569 Mon Sep 17 00:00:00 2001 From: zwimer Date: Tue, 25 Apr 2023 15:06:10 -0700 Subject: [PATCH 18/20] Hack that enables functionality in pyinstaller builds --- darkdetect/_mac_detect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/darkdetect/_mac_detect.py b/darkdetect/_mac_detect.py index 71c5a44..f27221b 100644 --- a/darkdetect/_mac_detect.py +++ b/darkdetect/_mac_detect.py @@ -86,8 +86,9 @@ class MacListener(BaseListener): def _listen(self) -> None: if not _can_listen: raise NotImplementedError("Optional dependencies not found; fix this with: pip install darkdetect[macos-listener]") + fix = "from multiprocessing.resource_tracker import main;" with subprocess.Popen( - (sys.executable, "-c", "import darkdetect as d; d.MacListener._listen_child()"), + (sys.executable, "-c", fix + "import darkdetect as d; d.MacListener._listen_child()"), stdout=subprocess.PIPE, universal_newlines=True, cwd=Path(__file__).parents[1], From 28b3c69848e90778886590602fc77128bcdb4fe3 Mon Sep 17 00:00:00 2001 From: Zachary Wimer Date: Tue, 25 Apr 2023 15:09:05 -0700 Subject: [PATCH 19/20] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c8a25b7..a81cd17 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ if not listener.stop(timeout=5): ## Known Issues 1. On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. -1. On macOS, using the listener API in a bundled app where `sys.executable` is not a python interpreter (such as pyinstaller builds), is not supported. +1. On macOS, using the listener API in a bundled app where `sys.executable` is not a python interpreter is not supported (though it may still work as it uses the same code path as `multiprocessing`). 1. On Windows, the after `Listener.stop(None)` is not supported as it may not die until another theme change is detected. Future invocations of `callback` will not be made, but the listener itself will persist. From c8a229a8bf04e4960b61e012ebd6cd9382261aef Mon Sep 17 00:00:00 2001 From: zwimer Date: Tue, 25 Apr 2023 19:01:15 -0700 Subject: [PATCH 20/20] Allow more strict pyinstaller builds and avoid hack for non-frozen builds --- darkdetect/_mac_detect.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/darkdetect/_mac_detect.py b/darkdetect/_mac_detect.py index f27221b..f1ab57f 100644 --- a/darkdetect/_mac_detect.py +++ b/darkdetect/_mac_detect.py @@ -1,5 +1,5 @@ #----------------------------------------------------------------------------- -# Copyright (C) 2019 Alberto Sottile +# Copyright (C) 2019 Alberto Sottile, Zachary Wimer # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- @@ -86,9 +86,14 @@ class MacListener(BaseListener): def _listen(self) -> None: if not _can_listen: raise NotImplementedError("Optional dependencies not found; fix this with: pip install darkdetect[macos-listener]") - fix = "from multiprocessing.resource_tracker import main;" + cmd = "import darkdetect as d; d.MacListener._listen_child()" + if getattr(sys, "frozen", False): + # This arrangement allows compatibility with pyinstaller and such (it is what multiprocessing does) + args = ("-B", "-s", "-S", "-E","-c", "from multiprocessing.resource_tracker import main;" + cmd) + else: + args = ("-B", "-c", cmd) with subprocess.Popen( - (sys.executable, "-c", fix + "import darkdetect as d; d.MacListener._listen_child()"), + (sys.executable, *args), stdout=subprocess.PIPE, universal_newlines=True, cwd=Path(__file__).parents[1],