From 71d5ced28a86795eb0eec19aa89f94a36929aa6b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 11 Nov 2024 10:45:36 +0100 Subject: [PATCH] Merge and trim the two utils modules (#13) --- rendercanvas/__init__.py | 2 +- rendercanvas/_coreutils.py | 261 +++++++++++++++++++++---------------- rendercanvas/_events.py | 3 +- rendercanvas/_gui_utils.py | 160 ----------------------- rendercanvas/_loop.py | 3 +- rendercanvas/auto.py | 2 +- rendercanvas/base.py | 2 +- rendercanvas/glfw.py | 2 +- rendercanvas/qt.py | 4 +- rendercanvas/wx.py | 4 +- tests/test_utils.py | 2 +- 11 files changed, 160 insertions(+), 285 deletions(-) delete mode 100644 rendercanvas/_gui_utils.py diff --git a/rendercanvas/__init__.py b/rendercanvas/__init__.py index 01af62f..77360fe 100644 --- a/rendercanvas/__init__.py +++ b/rendercanvas/__init__.py @@ -5,7 +5,7 @@ # ruff: noqa: F401 from ._version import __version__, version_info -from . import _gui_utils +from . import _coreutils from ._events import EventType from .base import BaseRenderCanvas, BaseLoop, BaseTimer diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index c8389ef..a93c36c 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -2,36 +2,25 @@ Core utilities that are loaded into the root namespace or used internally. """ +import os import re import sys import types -import atexit +import weakref import logging -import importlib.resources -from contextlib import ExitStack +import ctypes.util +from contextlib import contextmanager -# Our resources are most probably always on the file system. But in -# case they don't we have a nice exit handler to remove temporary files. -_resource_files = ExitStack() -atexit.register(_resource_files.close) - - -def get_resource_filename(name): - """Get the filename to a wgpu resource.""" - if sys.version_info < (3, 9): - context = importlib.resources.path("wgpu.resources", name) - else: - ref = importlib.resources.files("wgpu.resources") / name - context = importlib.resources.as_file(ref) - path = _resource_files.enter_context(context) - return str(path) +# %% Logging logger = logging.getLogger("rendercanvas") logger.setLevel(logging.WARNING) +err_hashes = {} + _re_wgpu_ob = re.compile(r"`<[a-z|A-Z]+-\([0-9]+, [0-9]+, [a-z|A-Z]+\)>`") @@ -42,6 +31,55 @@ def error_message_hash(message): return hash(message) +@contextmanager +def log_exception(kind): + """Context manager to log any exceptions, but only log a one-liner + for subsequent occurrences of the same error to avoid spamming by + repeating errors in e.g. a draw function or event callback. + """ + try: + yield + except Exception as err: + # Store exc info for postmortem debugging + exc_info = list(sys.exc_info()) + exc_info[2] = exc_info[2].tb_next # skip *this* function + sys.last_type, sys.last_value, sys.last_traceback = exc_info + # Show traceback, or a one-line summary + msg = str(err) + msgh = error_message_hash(msg) + if msgh not in err_hashes: + # Provide the exception, so the default logger prints a stacktrace. + # IDE's can get the exception from the root logger for PM debugging. + err_hashes[msgh] = 1 + logger.error(kind, exc_info=err) + else: + # We've seen this message before, return a one-liner instead. + err_hashes[msgh] = count = err_hashes[msgh] + 1 + msg = kind + ": " + msg.split("\n")[0].strip() + msg = msg if len(msg) <= 70 else msg[:69] + "…" + logger.error(msg + f" ({count})") + + +# %% Weak bindings + + +def weakbind(method): + """Replace a bound method with a callable object that stores the `self` using a weakref.""" + ref = weakref.ref(method.__self__) + class_func = method.__func__ + del method + + def proxy(*args, **kwargs): + self = ref() + if self is not None: + return class_func(self, *args, **kwargs) + + proxy.__name__ = class_func.__name__ + return proxy + + +# %% Enum + # We implement a custom enum class that's much simpler than Python's enum.Enum, # and simply maps to strings or ints. The enums are classes, so IDE's provide # autocompletion, and documenting with Sphinx is easy. That does mean we need a @@ -112,101 +150,100 @@ def __init__(self): raise RuntimeError("Cannot instantiate an enum.") -_flag_cache = {} # str -> int +# %% lib support -def str_flag_to_int(flag, s): - """Allow using strings for flags, i.e. 'READ' instead of wgpu.MapMode.READ. - No worries about repeated overhead, because the results are cached. - """ - cache_key = f"{flag.__name__}.{s}" # use class name - value = _flag_cache.get(cache_key, None) - - if value is None: - parts = [p.strip() for p in s.split("|")] - parts = [p for p in parts if p] - invalid_parts = [p for p in parts if p.startswith("_")] - if not parts or invalid_parts: - raise ValueError(f"Invalid flag value: {s}") - - value = 0 - for p in parts: - try: - v = flag.__dict__[p.upper()] - value += v - except KeyError: - raise ValueError(f"Invalid flag value for {flag}: '{p}'") from None - _flag_cache[cache_key] = value - - return value - - -class ApiDiff: - """Helper class to define differences in the API by annotating - methods. This way, these difference are made explicit, plus they're - logged so we can automatically include these changes in the docs. +QT_MODULE_NAMES = ["PySide6", "PyQt6", "PySide2", "PyQt5"] + + +def get_imported_qt_lib(): + """Get the name of the currently imported qt lib. + + Returns (name, has_application). The name is None when no qt lib is currently imported. """ - def __init__(self): - self.hidden = {} - self.added = {} - self.changed = {} - - def hide(self, func_or_text): - """Decorator to discard certain methods from the "reference" API. - Intended only for the base API where we deviate from WebGPU. - """ - return self._diff("hidden", func_or_text) - - def add(self, func_or_text): - """Decorator to add certain methods that are not part of the "reference" spec. - Intended for the base API where we implement additional/alternative API, - and in the backend implementations where additional methods are provided. - """ - return self._diff("added", func_or_text) - - def change(self, func_or_text): - """Decorator to mark certain methods as having a different signature - as the "reference" spec. Intended only for the base API where we deviate - from WebGPU. - """ - return self._diff("changed", func_or_text) - - def _diff(self, method, func_or_text): - def wrapper(f): - d = getattr(self, method) - name = f.__qualname__ if hasattr(f, "__qualname__") else f.fget.__qualname__ - d[name] = text - return f - - if callable(func_or_text): - text = None - return wrapper(func_or_text) - else: - text = func_or_text - return wrapper - - def remove_hidden_methods(self, scope): - """Call this to remove methods from the API that were decorated as hidden.""" - for name in self.hidden: - classname, _, methodname = name.partition(".") - cls = scope[classname] - delattr(cls, methodname) - - @property - def __doc__(self): - """Generate a docstring for this instance. This way we can - automatically document API differences. - """ - lines = [""] - for name, msg in self.hidden.items(): - line = f" * Hides ``{name}()``" - lines.append(f"{line} - {msg}" if msg else line) - for name, msg in self.added.items(): - line = f" * Adds ``{name}()``" - lines.append(f"{line} - {msg}" if msg else line) - for name, msg in self.changed.items(): - line = f" * Changes ``{name}()``" - lines.append(f"{line} - {msg}" if msg else line) - lines.append("") - return "\n".join(sorted(lines)) + # Get all imported qt libs + imported_libs = [] + for libname in QT_MODULE_NAMES: + qtlib = sys.modules.get(libname, None) + if qtlib is not None: + imported_libs.append(libname) + + # Get which of these have an application object + imported_libs_with_app = [] + for libname in imported_libs: + QtWidgets = sys.modules.get(libname + ".QtWidgets", None) # noqa: N806 + if QtWidgets: + app = QtWidgets.QApplication.instance() + if app is not None: + imported_libs_with_app.append(libname) + + # Return findings + if imported_libs_with_app: + return imported_libs_with_app[0], True + elif imported_libs: + return imported_libs[0], False + else: + return None, False + + +def asyncio_is_running(): + """Get whether there is currently a running asyncio loop.""" + asyncio = sys.modules.get("asyncio", None) + if asyncio is None: + return False + try: + loop = asyncio.get_running_loop() + except Exception: + loop = None + return loop is not None + + +# %% Linux window managers + + +SYSTEM_IS_WAYLAND = "wayland" in os.getenv("XDG_SESSION_TYPE", "").lower() + +if sys.platform.startswith("linux") and SYSTEM_IS_WAYLAND: + # Force glfw to use X11. Note that this does not work if glfw is already imported. + if "glfw" not in sys.modules: + os.environ["PYGLFW_LIBRARY_VARIANT"] = "x11" + # Force Qt to use X11. Qt is more flexible - it ok if e.g. PySide6 is already imported. + os.environ["QT_QPA_PLATFORM"] = "xcb" + # Force wx to use X11, probably. + os.environ["GDK_BACKEND"] = "x11" + + +_x11_display = None + + +def get_alt_x11_display(): + """Get (the pointer to) a process-global x11 display instance.""" + # Ideally we'd get the real display object used by the backend. + # But this is not always possible. In that case, using an alt display + # object can be used. + global _x11_display + assert sys.platform.startswith("linux") + if _x11_display is None: + x11 = ctypes.CDLL(ctypes.util.find_library("X11")) + x11.XOpenDisplay.restype = ctypes.c_void_p + _x11_display = x11.XOpenDisplay(None) + return _x11_display + + +_wayland_display = None + + +def get_alt_wayland_display(): + """Get (the pointer to) a process-global Wayland display instance.""" + # Ideally we'd get the real display object used by the backend. + # This creates a global object, similar to what we do for X11. + # Unfortunately, this segfaults, so it looks like the real display object + # is needed? Leaving this here for reference. + global _wayland_display + assert sys.platform.startswith("linux") + if _wayland_display is None: + wl = ctypes.CDLL(ctypes.util.find_library("wayland-client")) + wl.wl_display_connect.restype = ctypes.c_void_p + _wayland_display = wl.wl_display_connect(None) + return _wayland_display diff --git a/rendercanvas/_events.py b/rendercanvas/_events.py index 6355bf3..c51c744 100644 --- a/rendercanvas/_events.py +++ b/rendercanvas/_events.py @@ -1,8 +1,7 @@ import time from collections import defaultdict, deque -from ._gui_utils import log_exception -from ._coreutils import BaseEnum +from ._coreutils import log_exception, BaseEnum class EventType(BaseEnum): diff --git a/rendercanvas/_gui_utils.py b/rendercanvas/_gui_utils.py deleted file mode 100644 index f4cf5e5..0000000 --- a/rendercanvas/_gui_utils.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Private utilities.""" - -import os -import sys -import weakref -import logging -import ctypes.util -from contextlib import contextmanager - -from ._coreutils import error_message_hash - - -logger = logging.getLogger("rendercanvas") - - -# ===== lib support - -QT_MODULE_NAMES = ["PySide6", "PyQt6", "PySide2", "PyQt5"] - - -def get_imported_qt_lib(): - """Get the name of the currently imported qt lib. - - Returns (name, has_application). The name is None when no qt lib is currently imported. - """ - - # Get all imported qt libs - imported_libs = [] - for libname in QT_MODULE_NAMES: - qtlib = sys.modules.get(libname, None) - if qtlib is not None: - imported_libs.append(libname) - - # Get which of these have an application object - imported_libs_with_app = [] - for libname in imported_libs: - QtWidgets = sys.modules.get(libname + ".QtWidgets", None) # noqa: N806 - if QtWidgets: - app = QtWidgets.QApplication.instance() - if app is not None: - imported_libs_with_app.append(libname) - - # Return findings - if imported_libs_with_app: - return imported_libs_with_app[0], True - elif imported_libs: - return imported_libs[0], False - else: - return None, False - - -def asyncio_is_running(): - """Get whether there is currently a running asyncio loop.""" - asyncio = sys.modules.get("asyncio", None) - if asyncio is None: - return False - try: - loop = asyncio.get_running_loop() - except Exception: - loop = None - return loop is not None - - -# ===== Logging and more - -err_hashes = {} - - -@contextmanager -def log_exception(kind): - """Context manager to log any exceptions, but only log a one-liner - for subsequent occurrences of the same error to avoid spamming by - repeating errors in e.g. a draw function or event callback. - """ - try: - yield - except Exception as err: - # Store exc info for postmortem debugging - exc_info = list(sys.exc_info()) - exc_info[2] = exc_info[2].tb_next # skip *this* function - sys.last_type, sys.last_value, sys.last_traceback = exc_info - # Show traceback, or a one-line summary - msg = str(err) - msgh = error_message_hash(msg) - if msgh not in err_hashes: - # Provide the exception, so the default logger prints a stacktrace. - # IDE's can get the exception from the root logger for PM debugging. - err_hashes[msgh] = 1 - logger.error(kind, exc_info=err) - else: - # We've seen this message before, return a one-liner instead. - err_hashes[msgh] = count = err_hashes[msgh] + 1 - msg = kind + ": " + msg.split("\n")[0].strip() - msg = msg if len(msg) <= 70 else msg[:69] + "…" - logger.error(msg + f" ({count})") - - -def weakbind(method): - """Replace a bound method with a callable object that stores the `self` using a weakref.""" - ref = weakref.ref(method.__self__) - class_func = method.__func__ - del method - - def proxy(*args, **kwargs): - self = ref() - if self is not None: - return class_func(self, *args, **kwargs) - - proxy.__name__ = class_func.__name__ - return proxy - - -# ===== Linux window managers - - -SYSTEM_IS_WAYLAND = "wayland" in os.getenv("XDG_SESSION_TYPE", "").lower() - -if sys.platform.startswith("linux") and SYSTEM_IS_WAYLAND: - # Force glfw to use X11. Note that this does not work if glfw is already imported. - if "glfw" not in sys.modules: - os.environ["PYGLFW_LIBRARY_VARIANT"] = "x11" - # Force Qt to use X11. Qt is more flexible - it ok if e.g. PySide6 is already imported. - os.environ["QT_QPA_PLATFORM"] = "xcb" - # Force wx to use X11, probably. - os.environ["GDK_BACKEND"] = "x11" - - -_x11_display = None - - -def get_alt_x11_display(): - """Get (the pointer to) a process-global x11 display instance.""" - # Ideally we'd get the real display object used by the backend. - # But this is not always possible. In that case, using an alt display - # object can be used. - global _x11_display - assert sys.platform.startswith("linux") - if _x11_display is None: - x11 = ctypes.CDLL(ctypes.util.find_library("X11")) - x11.XOpenDisplay.restype = ctypes.c_void_p - _x11_display = x11.XOpenDisplay(None) - return _x11_display - - -_wayland_display = None - - -def get_alt_wayland_display(): - """Get (the pointer to) a process-global Wayland display instance.""" - # Ideally we'd get the real display object used by the backend. - # This creates a global object, similar to what we do for X11. - # Unfortunately, this segfaults, so it looks like the real display object - # is needed? Leaving this here for reference. - global _wayland_display - assert sys.platform.startswith("linux") - if _wayland_display is None: - wl = ctypes.CDLL(ctypes.util.find_library("wayland-client")) - wl.wl_display_connect.restype = ctypes.c_void_p - _wayland_display = wl.wl_display_connect(None) - return _wayland_display diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 91f1842..1bcbf84 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -5,8 +5,7 @@ import time import weakref -from ._gui_utils import log_exception -from ._coreutils import BaseEnum +from ._coreutils import log_exception, BaseEnum # Note: technically, we could have a global loop proxy object that defers to any of the other loops. # That would e.g. allow using glfw with qt together. Probably a too weird use-case for the added complexity. diff --git a/rendercanvas/auto.py b/rendercanvas/auto.py index 88ca771..aa9c8e0 100644 --- a/rendercanvas/auto.py +++ b/rendercanvas/auto.py @@ -10,7 +10,7 @@ import os import sys import importlib -from ._gui_utils import logger, QT_MODULE_NAMES, get_imported_qt_lib, asyncio_is_running +from ._coreutils import logger, QT_MODULE_NAMES, get_imported_qt_lib, asyncio_is_running # Note that wx is not in here, because it does not (yet) fully implement base.BaseRenderCanvas diff --git a/rendercanvas/base.py b/rendercanvas/base.py index e1c641b..0f2e2f4 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -2,7 +2,7 @@ from ._events import EventEmitter, EventType # noqa: F401 from ._loop import Scheduler, BaseLoop, BaseTimer # noqa: F401 -from ._gui_utils import log_exception +from ._coreutils import log_exception # Notes on naming and prefixes: diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 1480e81..1a8b9c3 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -16,7 +16,7 @@ from .base import BaseRenderCanvas from .asyncio import AsyncioLoop -from ._gui_utils import SYSTEM_IS_WAYLAND, weakbind, logger +from ._coreutils import SYSTEM_IS_WAYLAND, weakbind, logger # Make sure that glfw is new enough diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 807e602..06eb257 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -13,7 +13,7 @@ BaseLoop, BaseTimer, ) -from ._gui_utils import ( +from ._coreutils import ( logger, SYSTEM_IS_WAYLAND, get_alt_x11_display, @@ -187,7 +187,7 @@ def _get_surface_ids(self): } elif sys.platform.startswith("linux"): if False: - # We fall back to XWayland, see _gui_utils.py + # We fall back to XWayland, see _coreutils.py return { "platform": "wayland", "window": int(self.winId()), diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 5c0332b..415b704 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -10,7 +10,7 @@ import wx -from ._gui_utils import ( +from ._coreutils import ( logger, SYSTEM_IS_WAYLAND, get_alt_x11_display, @@ -183,7 +183,7 @@ def _get_surface_ids(self): } elif sys.platform.startswith("linux"): if False: - # We fall back to XWayland, see _gui_utils.py + # We fall back to XWayland, see _coreutils.py return { "platform": "wayland", "window": int(self.GetHandle()), diff --git a/tests/test_utils.py b/tests/test_utils.py index 3464ab9..ebe43b2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,7 @@ def test_weakbind(): - weakbind = rendercanvas._gui_utils.weakbind + weakbind = rendercanvas._coreutils.weakbind xx = []