diff options
author | Florian Bruhin <me@the-compiler.org> | 2023-06-14 20:04:52 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-06-14 20:04:52 +0200 |
commit | e8ed81d9e505fbc13378d920418c0bbb20a5443d (patch) | |
tree | 996dfb30edc987043bbee5b0003fb6729d0e6314 | |
parent | 4bf1e69783c19195fe5a6e43474d274e35c3cfa8 (diff) | |
parent | d6af5394542a96818ef8389227c7e4020f1c5f9f (diff) | |
download | qutebrowser-e8ed81d9e505fbc13378d920418c0bbb20a5443d.tar.gz qutebrowser-e8ed81d9e505fbc13378d920418c0bbb20a5443d.zip |
Merge pull request #7741 from qutebrowser/better-qt-wrappers
Resolve remaining Qt wrapper selection UX issues
27 files changed, 1005 insertions, 138 deletions
@@ -62,6 +62,7 @@ min-version = 3.7.0 max-complexity = 12 per-file-ignores = qutebrowser/api/hook.py : N801 + qutebrowser/qt/*.py : F403 tests/* : B011,B028,D100,D101 tests/unit/browser/test_history.py : D100,D101,N806 tests/helpers/fixtures.py : D100,D101,N806 diff --git a/qutebrowser/misc/backendproblem.py b/qutebrowser/misc/backendproblem.py index 4c0e184ac..84eb120be 100644 --- a/qutebrowser/misc/backendproblem.py +++ b/qutebrowser/misc/backendproblem.py @@ -25,10 +25,12 @@ import functools import html import enum import shutil +import os.path import argparse import dataclasses from typing import Any, Optional, Sequence, Tuple +from qutebrowser.qt import machinery from qutebrowser.qt.core import Qt from qutebrowser.qt.widgets import (QDialog, QPushButton, QHBoxLayout, QVBoxLayout, QLabel, QMessageBox, QWidget) @@ -97,6 +99,8 @@ def _error_text( f"setting the <i>backend = '{other_setting}'</i> option " f"(if you have a <i>config.py</i> file, you'll need to set " f"this manually). {warning}</p>") + + text += f"<p>{machinery.INFO.to_html()}</p>" return text @@ -260,9 +264,11 @@ class _BackendProblemChecker: "<p>The errors encountered were:<ul>" "<li><b>QtWebKit:</b> {webkit_error}" "<li><b>QtWebEngine:</b> {webengine_error}" - "</ul></p>".format( + "</ul></p><p>{info}</p>".format( webkit_error=html.escape(imports.webkit_error), - webengine_error=html.escape(imports.webengine_error))) + webengine_error=html.escape(imports.webengine_error), + info=machinery.INFO.to_html(), + )) errbox = msgbox.msgbox(parent=None, title="No backend library found!", text=text, @@ -328,28 +334,40 @@ class _BackendProblemChecker: """Ask if there are Chromium downgrades or a Qt 5 -> 6 upgrade.""" versions = version.qtwebengine_versions(avoid_init=True) change = configfiles.state.chromium_version_changed + info = f"<br><br>{machinery.INFO.to_html()}" + if machinery.INFO.reason == machinery.SelectionReason.auto: + info += ( + "<br><br>" + "You can use <tt>--qt-wrapper</tt> or set <tt>QUTE_QT_WRAPPER</tt> " + "in your environment to override this." + ) + webengine_data_dir = os.path.join(standarddir.data(), "webengine") + if change == configfiles.VersionChange.major: - # FIXME:qt6 Remove this before the release, as it typically should - # not concern users? + icon = QMessageBox.Icon.Information text = ( "Chromium/QtWebEngine upgrade detected:<br>" f"You are <b>upgrading to QtWebEngine {versions.webengine}</b> but " "used Qt 5 for the last qutebrowser launch.<br><br>" "Data managed by Chromium will be upgraded. This is a <b>one-way " "operation:</b> If you open qutebrowser with Qt 5 again later, any " - "Chromium data will be invalid and discarded.<br><br>" + "Chromium data will be <b>invalid and discarded</b>.<br><br>" "This affects page data such as cookies, but not data managed by " - "qutebrowser, such as your configuration or <tt>:open</tt> history." - ) + "qutebrowser, such as your configuration or <tt>:open</tt> history.<br>" + f"The affected data is in <tt>{webengine_data_dir}</tt>." + ) + info elif change == configfiles.VersionChange.downgrade: + icon = QMessageBox.Icon.Warning text = ( "Chromium/QtWebEngine downgrade detected:<br>" f"You are <b>downgrading to QtWebEngine {versions.webengine}</b>." "<br><br>" - "Data managed by Chromium will be discarded if you continue.<br><br>" + "Data managed by Chromium <b>will be discarded</b> if you continue." + "<br><br>" "This affects page data such as cookies, but not data managed by " - "qutebrowser, such as your configuration or <tt>:open</tt> history." - ) + "qutebrowser, such as your configuration or <tt>:open</tt> history.<br>" + f"The affected data is in <tt>{webengine_data_dir}</tt>." + ) + info else: return @@ -357,7 +375,7 @@ class _BackendProblemChecker: parent=None, title="QtWebEngine version change", text=text, - icon=QMessageBox.Icon.Warning, + icon=icon, plain_text=False, buttons=QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Abort, ) diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index ef7814ab1..40f6dbd49 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -35,6 +35,7 @@ import traceback import signal import importlib import datetime +from typing import NoReturn try: import tkinter except ImportError: @@ -42,6 +43,10 @@ except ImportError: # NOTE: No qutebrowser or PyQt import should be done here, as some early # initialization needs to take place before that! +# +# The machinery module is an exception, as it also is required to never import Qt +# itself at import time. +from qutebrowser.qt import machinery START_TIME = datetime.datetime.now() @@ -136,11 +141,26 @@ def init_faulthandler(fileobj=sys.__stderr__): # pylint: enable=no-member,useless-suppression -def check_pyqt(): - """Check if PyQt core modules (QtCore/QtWidgets) are installed.""" - from qutebrowser.qt import machinery +def _fatal_qt_error(text: str) -> NoReturn: + """Show a fatal error about Qt being missing.""" + if tkinter and '--no-err-windows' not in sys.argv: + root = tkinter.Tk() + root.withdraw() + tkinter.messagebox.showerror("qutebrowser: Fatal error!", text) + else: + print(text, file=sys.stderr) + if '--debug' in sys.argv or '--no-err-windows' in sys.argv: + print(file=sys.stderr) + traceback.print_exc() + sys.exit(1) + + +def check_qt_available(info: machinery.SelectionInfo) -> None: + """Check if Qt core modules (QtCore/QtWidgets) are installed.""" + if info.wrapper is None: + _fatal_qt_error(f"No Qt wrapper was importable.\n\n{info}") - packages = [f'{machinery.PACKAGE}.QtCore', f'{machinery.PACKAGE}.QtWidgets'] + packages = [f'{info.wrapper}.QtCore', f'{info.wrapper}.QtWidgets'] for name in packages: try: importlib.import_module(name) @@ -150,16 +170,8 @@ def check_pyqt(): text = text.replace('</b>', '') text = text.replace('<br />', '\n') text = text.replace('%ERROR%', str(e)) - if tkinter and '--no-err-windows' not in sys.argv: - root = tkinter.Tk() - root.withdraw() - tkinter.messagebox.showerror("qutebrowser: Fatal error!", text) - else: - print(text, file=sys.stderr) - if '--debug' in sys.argv or '--no-err-windows' in sys.argv: - print(file=sys.stderr) - traceback.print_exc() - sys.exit(1) + text += '\n\n' + str(info) + _fatal_qt_error(text) def qt_version(qversion=None, qt_version_str=None): @@ -240,14 +252,13 @@ def _check_modules(modules): def check_libraries(): """Check if all needed Python libraries are installed.""" - from qutebrowser.qt import machinery modules = { 'jinja2': _missing_str("jinja2"), 'yaml': _missing_str("PyYAML"), } for subpkg in ['QtQml', 'QtOpenGL', 'QtDBus']: - package = f'{machinery.PACKAGE}.{subpkg}' + package = f'{machinery.INFO.wrapper}.{subpkg}' modules[package] = _missing_str(package) if sys.version_info < (3, 9): @@ -292,6 +303,7 @@ def init_log(args): from qutebrowser.utils import log log.init_log(args) log.init.debug("Log initialized.") + log.init.debug(str(machinery.INFO)) def check_optimize_flag(): @@ -329,9 +341,11 @@ def early_init(args): # First we initialize the faulthandler as early as possible, so we # theoretically could catch segfaults occurring later during earlyinit. init_faulthandler() + # Then we configure the selected Qt wrapper + info = machinery.init(args) # Here we check if QtCore is available, and if not, print a message to the # console or via Tk. - check_pyqt() + check_qt_available(info) # Init logging as early as possible init_log(args) # Now we can be sure QtCore is available, so we can print dialogs on diff --git a/qutebrowser/qt/core.py b/qutebrowser/qt/core.py index b09459f6f..d9323f877 100644 --- a/qutebrowser/qt/core.py +++ b/qutebrowser/qt/core.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt Core. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtcore-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtCore import * diff --git a/qutebrowser/qt/dbus.py b/qutebrowser/qt/dbus.py index 6ef8e55f3..350c98dc5 100644 --- a/qutebrowser/qt/dbus.py +++ b/qutebrowser/qt/dbus.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt DBus. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtdbus-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtDBus import * diff --git a/qutebrowser/qt/gui.py b/qutebrowser/qt/gui.py index ce4780f42..6c08bc3c4 100644 --- a/qutebrowser/qt/gui.py +++ b/qutebrowser/qt/gui.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import,unused-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import,unused-import + +"""Wrapped Qt imports for Qt Gui. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtgui-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtGui import * diff --git a/qutebrowser/qt/machinery.py b/qutebrowser/qt/machinery.py index 3ddafad65..15ff72b17 100644 --- a/qutebrowser/qt/machinery.py +++ b/qutebrowser/qt/machinery.py @@ -1,10 +1,23 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring -# flake8: noqa +# pyright: reportConstantRedefinition=false + +"""Qt wrapper selection. + +Contains selection logic and globals for Qt wrapper selection. +""" + +# NOTE: No qutebrowser or PyQt import should be done here (at import time), +# as some early initialization needs to take place before that! import os +import sys +import enum +import html +import argparse +import warnings import importlib +import dataclasses +from typing import Optional, Dict # Packagers: Patch the line below to change the default wrapper for Qt 6 packages, e.g.: # sed -i 's/_DEFAULT_WRAPPER = "PyQt5"/_DEFAULT_WRAPPER = "PyQt6"/' qutebrowser/qt/machinery.py @@ -12,7 +25,7 @@ import importlib # Users: Set the QUTE_QT_WRAPPER environment variable to change the default wrapper. _DEFAULT_WRAPPER = "PyQt5" -_WRAPPERS = [ +WRAPPERS = [ "PyQt6", "PyQt5", # Needs more work @@ -21,7 +34,7 @@ _WRAPPERS = [ class Error(Exception): - pass + """Base class for all exceptions in this module.""" class Unavailable(Error, ImportError): @@ -29,58 +42,247 @@ class Unavailable(Error, ImportError): """Raised when a module is unavailable with the given wrapper.""" def __init__(self) -> None: - super().__init__(f"Unavailable with {WRAPPER}") + super().__init__(f"Unavailable with {INFO.wrapper}") + + +class NoWrapperAvailableError(Error, ImportError): + + """Raised when no Qt wrapper is available.""" + + def __init__(self, info: "SelectionInfo") -> None: + super().__init__(f"No Qt wrapper was importable.\n\n{info}") class UnknownWrapper(Error): - pass + """Raised when an Qt module is imported but the wrapper values are unknown. + + Should never happen (unless a new wrapper is added). + """ + + +class SelectionReason(enum.Enum): + + """Reasons for selecting a Qt wrapper.""" + + #: The wrapper was selected via --qt-wrapper. + cli = "--qt-wrapper" + + #: The wrapper was selected via the QUTE_QT_WRAPPER environment variable. + env = "QUTE_QT_WRAPPER" + + #: The wrapper was selected via autoselection. + auto = "autoselect" + + #: The default wrapper was selected. + default = "default" + + #: The wrapper was faked/patched out (e.g. in tests). + fake = "fake" + #: The reason was not set. + unknown = "unknown" -def _autoselect_wrapper(): - for wrapper in _WRAPPERS: + +@dataclasses.dataclass +class SelectionInfo: + """Information about outcomes of importing Qt wrappers.""" + + wrapper: Optional[str] = None + outcomes: Dict[str, str] = dataclasses.field(default_factory=dict) + reason: SelectionReason = SelectionReason.unknown + + def set_module_error(self, name: str, error: Exception) -> None: + """Set the outcome for a module import.""" + self.outcomes[name] = f"{type(error).__name__}: {error}" + + def use_wrapper(self, wrapper: str) -> None: + """Set the wrapper to use.""" + self.wrapper = wrapper + self.outcomes[wrapper] = "success" + + def __str__(self) -> str: + if not self.outcomes: + # No modules were tried to be imported (no autoselection) + # Thus, we can have a shorter output instead of adding noise. + return f"Qt wrapper: {self.wrapper} (via {self.reason.value})" + + lines = ["Qt wrapper info:"] + for wrapper in WRAPPERS: + outcome = self.outcomes.get(wrapper, "not imported") + lines.append(f" {wrapper}: {outcome}") + + lines.append(f" -> selected: {self.wrapper} (via {self.reason.value})") + return "\n".join(lines) + + def to_html(self) -> str: + return html.escape(str(self)).replace("\n", "<br>") + + +def _autoselect_wrapper() -> SelectionInfo: + """Autoselect a Qt wrapper. + + This goes through all wrappers defined in WRAPPER. + The first one which can be imported is returned. + """ + info = SelectionInfo(reason=SelectionReason.auto) + + for wrapper in WRAPPERS: try: importlib.import_module(wrapper) - except ImportError: - # FIXME:qt6 show/log this somewhere? + except ModuleNotFoundError as e: + # Wrapper not available -> try the next one. + info.set_module_error(wrapper, e) continue - return wrapper + except ImportError as e: + # Any other ImportError -> stop to surface the error. + info.set_module_error(wrapper, e) + break + + # Wrapper imported successfully -> use it. + info.use_wrapper(wrapper) + return info + + # SelectionInfo with wrapper=None but all error reports + return info + + +def _select_wrapper(args: Optional[argparse.Namespace]) -> SelectionInfo: + """Select a Qt wrapper. - wrappers = ", ".join(_WRAPPERS) - raise Error(f"No Qt wrapper found, tried {wrappers}") + - If --qt-wrapper is given, use that. + - Otherwise, if the QUTE_QT_WRAPPER environment variable is set, use that. + - Otherwise, use PyQt5 (FIXME:qt6 autoselect). + """ + # If any Qt wrapper has been imported before this, something strange might + # be happening. + for name in WRAPPERS: + if name in sys.modules: + warnings.warn(f"{name} already imported", stacklevel=1) + if args is not None and args.qt_wrapper is not None: + assert args.qt_wrapper in WRAPPERS, args.qt_wrapper # ensured by argparse + return SelectionInfo(wrapper=args.qt_wrapper, reason=SelectionReason.cli) -def _select_wrapper(): env_var = "QUTE_QT_WRAPPER" env_wrapper = os.environ.get(env_var) - if env_wrapper is None: - # FIXME:qt6 Go back to the auto-detection once ready - # return _autoselect_wrapper() - return _DEFAULT_WRAPPER - - if env_wrapper not in _WRAPPERS: - raise Error(f"Unknown wrapper {env_wrapper} set via {env_var}, " - f"allowed: {', '.join(_WRAPPERS)}") - - return env_wrapper - - -WRAPPER = _select_wrapper() -USE_PYQT5 = WRAPPER == "PyQt5" -USE_PYQT6 = WRAPPER == "PyQt6" -USE_PYSIDE6 = WRAPPER == "PySide6" -assert USE_PYQT5 ^ USE_PYQT6 ^ USE_PYSIDE6 - -IS_QT5 = USE_PYQT5 -IS_QT6 = USE_PYQT6 or USE_PYSIDE6 -IS_PYQT = USE_PYQT5 or USE_PYQT6 -IS_PYSIDE = USE_PYSIDE6 -assert IS_QT5 ^ IS_QT6 -assert IS_PYQT ^ IS_PYSIDE - - -if USE_PYQT5: - PACKAGE = "PyQt5" -elif USE_PYQT6: - PACKAGE = "PyQt6" -elif USE_PYSIDE6: - PACKAGE = "PySide6" + if env_wrapper: + if env_wrapper == "auto": + return _autoselect_wrapper() + elif env_wrapper not in WRAPPERS: + raise Error(f"Unknown wrapper {env_wrapper} set via {env_var}, " + f"allowed: {', '.join(WRAPPERS)}") + return SelectionInfo(wrapper=env_wrapper, reason=SelectionReason.env) + + # FIXME:qt6 Go back to the auto-detection once ready + # FIXME:qt6 Make sure to still consider _DEFAULT_WRAPPER for packagers + # (rename to _WRAPPER_OVERRIDE since our sed command is broken anyways then?) + # return _autoselect_wrapper() + return SelectionInfo(wrapper=_DEFAULT_WRAPPER, reason=SelectionReason.default) + + +# Values are set in init(). If you see a NameError here, it means something tried to +# import Qt (or check for its availability) before machinery.init() was called. + +#: Information about the wrapper that ended up being selected. +#: Should not be used directly, use one of the USE_* or IS_* constants below +#: instead, as those are supported by type checking. +INFO: SelectionInfo + +#: Whether we're using PyQt5. Consider using IS_QT5 or IS_PYQT instead. +USE_PYQT5: bool + +#: Whether we're using PyQt6. Consider using IS_QT6 or IS_PYQT instead. +USE_PYQT6: bool + +#: Whether we're using PySide6. Consider using IS_QT6 or IS_PYSIDE instead. +USE_PYSIDE6: bool + +#: Whether we are using any Qt 5 wrapper. +IS_QT5: bool + +#: Whether we are using any Qt 6 wrapper. +IS_QT6: bool + +#: Whether we are using any PyQt wrapper. +IS_PYQT: bool + +#: Whether we are using any PySide wrapper. +IS_PYSIDE: bool + +_initialized = False + + +def _set_globals(info: SelectionInfo) -> None: + """Set all global variables in this module based on the given SelectionInfo. + + Those are split into multiple global variables because that way we can teach mypy + about them via --always-true and --always-false, see tox.ini. + """ + global INFO, USE_PYQT5, USE_PYQT6, USE_PYSIDE6, IS_QT5, IS_QT6, \ + IS_PYQT, IS_PYSIDE, _initialized + + assert info.wrapper is not None, info + assert not _initialized + + _initialized = True + INFO = info + USE_PYQT5 = info.wrapper == "PyQt5" + USE_PYQT6 = info.wrapper == "PyQt6" + USE_PYSIDE6 = info.wrapper == "PySide6" + assert USE_PYQT5 + USE_PYQT6 + USE_PYSIDE6 == 1 + + IS_QT5 = USE_PYQT5 + IS_QT6 = USE_PYQT6 or USE_PYSIDE6 + IS_PYQT = USE_PYQT5 or USE_PYQT6 + IS_PYSIDE = USE_PYSIDE6 + assert IS_QT5 ^ IS_QT6 + assert IS_PYQT ^ IS_PYSIDE + + +def init_implicit() -> None: + """Initialize Qt wrapper globals implicitly at Qt import time. + + This gets called when any qutebrowser.qt module is imported, and implicitly + initializes the Qt wrapper globals. + + After this is called, no explicit initialization via machinery.init() is possible + anymore - thus, this should never be called before init() when running qutebrowser + as an application (and any further calls will be a no-op). + + However, this ensures that any qutebrowser module can be imported without + having to worry about machinery.init(). This is useful for e.g. tests or + manual interactive usage of the qutebrowser code. + """ + if _initialized: + # Implicit initialization can happen multiple times + # (all subsequent calls are a no-op) + return + + info = _select_wrapper(args=None) + if info.wrapper is None: + raise NoWrapperAvailableError(info) + + _set_globals(info) + + +def init(args: argparse.Namespace) -> SelectionInfo: + """Initialize Qt wrapper globals during qutebrowser application start. + + This gets called from earlyinit.py, i.e. after we have an argument parser, + but before any kinds of Qt usage. This allows `args` to be passed, which is + used to select the Qt wrapper (if --qt-wrapper is given). + + If any qutebrowser.qt module is imported before this, init_implicit() will be called + instead, which means this can't be called anymore. + """ + if _initialized: + raise Error("init() already called before application init") + + info = _select_wrapper(args) + if info.wrapper is not None: + _set_globals(info) + + # If info is None here (no Qt wrapper available), we'll show an error later + # in earlyinit.py. + + return info diff --git a/qutebrowser/qt/network.py b/qutebrowser/qt/network.py index 6836d6226..9fbd91ec3 100644 --- a/qutebrowser/qt/network.py +++ b/qutebrowser/qt/network.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt Network. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtnetwork-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtNetwork import * diff --git a/qutebrowser/qt/opengl.py b/qutebrowser/qt/opengl.py index a04fb9a29..6f94fa3d9 100644 --- a/qutebrowser/qt/opengl.py +++ b/qutebrowser/qt/opengl.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-import + +"""Wrapped Qt imports for Qt OpenGL. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtopengl-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtOpenGL import * diff --git a/qutebrowser/qt/printsupport.py b/qutebrowser/qt/printsupport.py index 08a29638e..c1736b7e3 100644 --- a/qutebrowser/qt/printsupport.py +++ b/qutebrowser/qt/printsupport.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt Print Support. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtprintsupport-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtPrintSupport import * diff --git a/qutebrowser/qt/qml.py b/qutebrowser/qt/qml.py index faa82df48..da45afb36 100644 --- a/qutebrowser/qt/qml.py +++ b/qutebrowser/qt/qml.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt QML. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtqml-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtQml import * diff --git a/qutebrowser/qt/sip.py b/qutebrowser/qt/sip.py index 2cfd9c82f..7e4bf246d 100644 --- a/qutebrowser/qt/sip.py +++ b/qutebrowser/qt/sip.py @@ -1,31 +1,37 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import,no-else-raise -# flake8: noqa +# pylint: disable=wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for PyQt5.sip/PyQt6.sip. + +All code in qutebrowser should use this module instead of importing from +PyQt/sip directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the PyQt6.sip API: +https://www.riverbankcomputing.com/static/Docs/PyQt6/api/sip/sip-module.html + +Note that we don't yet abstract between PySide/PyQt here. +""" from qutebrowser.qt import machinery -# While upstream recommends using PyQt6.sip ever since PyQt6 5.11, some distributions -# still package later versions of PyQt6 with a top-level "sip" rather than "PyQt6.sip". -_VENDORED_SIP = False +machinery.init_implicit() -if machinery.USE_PYSIDE6: +if machinery.USE_PYSIDE6: # pylint: disable=no-else-raise raise machinery.Unavailable() elif machinery.USE_PYQT5: try: from PyQt5.sip import * - _VENDORED_SIP = True except ImportError: - pass + from sip import * # type: ignore[import] elif machinery.USE_PYQT6: try: from PyQt6.sip import * - _VENDORED_SIP = True except ImportError: - pass - + # While upstream recommends using PyQt5.sip ever since PyQt5 5.11, some + # distributions still package later versions of PyQt5 with a top-level + # "sip" rather than "PyQt5.sip". + from sip import * else: raise machinery.UnknownWrapper() - -if not _VENDORED_SIP: - from sip import * # type: ignore[import] # pylint: disable=import-error diff --git a/qutebrowser/qt/sql.py b/qutebrowser/qt/sql.py index a8c46ebb1..c2ed624ac 100644 --- a/qutebrowser/qt/sql.py +++ b/qutebrowser/qt/sql.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt SQL. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtsql-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtSql import * diff --git a/qutebrowser/qt/test.py b/qutebrowser/qt/test.py index e8e0189d0..d1d266902 100644 --- a/qutebrowser/qt/test.py +++ b/qutebrowser/qt/test.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt Test. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qttest-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtTest import * diff --git a/qutebrowser/qt/webenginecore.py b/qutebrowser/qt/webenginecore.py index b1e650d24..f45d13f54 100644 --- a/qutebrowser/qt/webenginecore.py +++ b/qutebrowser/qt/webenginecore.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import,unused-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import,unused-import + +"""Wrapped Qt imports for Qt WebEngine Core. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtwebenginecore-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtWebEngineCore import * diff --git a/qutebrowser/qt/webenginewidgets.py b/qutebrowser/qt/webenginewidgets.py index 922acf869..9d11d5f79 100644 --- a/qutebrowser/qt/webenginewidgets.py +++ b/qutebrowser/qt/webenginewidgets.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt WebEngine Widgets. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtwebenginewidgets-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtWebEngineWidgets import * diff --git a/qutebrowser/qt/webkit.py b/qutebrowser/qt/webkit.py index 616560d49..17516d96c 100644 --- a/qutebrowser/qt/webkit.py +++ b/qutebrowser/qt/webkit.py @@ -1,12 +1,24 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,wildcard-import,no-else-raise -# flake8: noqa +# pylint: disable=wildcard-import + +"""Wrapped Qt imports for Qt WebKit. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6 +(though WebKit is only supported with Qt 5). + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the QtWebKit 5.212 API: +https://qtwebkit.github.io/doc/qtwebkit/qtwebkit-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + -if machinery.USE_PYSIDE6: +if machinery.USE_PYSIDE6: # pylint: disable=no-else-raise raise machinery.Unavailable() elif machinery.USE_PYQT5: from PyQt5.QtWebKit import * diff --git a/qutebrowser/qt/webkitwidgets.py b/qutebrowser/qt/webkitwidgets.py index fc5228b31..d6e7254f6 100644 --- a/qutebrowser/qt/webkitwidgets.py +++ b/qutebrowser/qt/webkitwidgets.py @@ -1,10 +1,22 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,wildcard-import,no-else-raise -# flake8: noqa +# pylint: disable=wildcard-import,no-else-raise + +"""Wrapped Qt imports for Qt WebKit Widgets. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6 +(though WebKit is only supported with Qt 5). + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the QtWebKit 5.212 API: +https://qtwebkit.github.io/doc/qtwebkit/qtwebkitwidgets-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: raise machinery.Unavailable() diff --git a/qutebrowser/qt/widgets.py b/qutebrowser/qt/widgets.py index 63e46c359..0f43779a6 100644 --- a/qutebrowser/qt/widgets.py +++ b/qutebrowser/qt/widgets.py @@ -1,10 +1,21 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# FIXME:qt6 (lint) -# pylint: disable=missing-module-docstring,import-error,wildcard-import,unused-wildcard-import -# flake8: noqa +# pylint: disable=import-error,wildcard-import,unused-wildcard-import + +"""Wrapped Qt imports for Qt Widgets. + +All code in qutebrowser should use this module instead of importing from +PyQt/PySide directly. This allows supporting both Qt 5 and Qt 6. + +See machinery.py for details on how Qt wrapper selection works. + +Any API exported from this module is based on the Qt 6 API: +https://doc.qt.io/qt-6/qtwidgets-index.html +""" from qutebrowser.qt import machinery +machinery.init_implicit() + if machinery.USE_PYSIDE6: from PySide6.QtWidgets import * diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py index ab2488301..2a9b340e1 100644 --- a/qutebrowser/qutebrowser.py +++ b/qutebrowser/qutebrowser.py @@ -54,6 +54,7 @@ check_python_version() import argparse # FIXME:qt6 (lint): disable=wrong-import-order from qutebrowser.misc import earlyinit +from qutebrowser.qt import machinery def get_argparser(): @@ -82,6 +83,11 @@ def get_argparser(): "qutebrowser instance running.") parser.add_argument('--backend', choices=['webkit', 'webengine'], help="Which backend to use.") + parser.add_argument('--qt-wrapper', choices=machinery.WRAPPERS, + help="Which Qt wrapper to use. This can also be set " + "via the QUTE_QT_WRAPPER environment variable. " + "If both are set, the command line argument takes " + "precedence.") parser.add_argument('--desktop-file-name', default="org.qutebrowser.qutebrowser", help="Set the base name of the desktop entry for this " diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 045c2478e..0cebead58 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -882,6 +882,8 @@ def version_info() -> str: platform.python_version()), 'PyQt: {}'.format(PYQT_VERSION_STR), '', + str(machinery.INFO), + '', ] lines += _module_versions() diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 6829c6b39..b4b46fdb3 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -143,6 +143,9 @@ def whitelist_generator(): # noqa: C901 yield 'ParserDictType' yield 'qutebrowser.config.configutils.Values._VmapKeyType' + # used in tests + yield 'qutebrowser.qt.machinery.SelectionReason.fake' + # ELF yield 'qutebrowser.misc.elf.Endianness.big' for name in ['phoff', 'ehsize', 'phentsize', 'phnum']: diff --git a/tests/conftest.py b/tests/conftest.py index 622286a54..0da3a1f81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,11 +116,11 @@ def _apply_platform_markers(config, item): ('qt5_only', pytest.mark.skipif, not machinery.IS_QT5, - f"Only runs on Qt 5, not {machinery.WRAPPER}"), + f"Only runs on Qt 5, not {machinery.INFO.wrapper}"), ('qt6_only', pytest.mark.skipif, not machinery.IS_QT6, - f"Only runs on Qt 6, not {machinery.WRAPPER}"), + f"Only runs on Qt 6, not {machinery.INFO.wrapper}"), ('qt5_xfail', pytest.mark.xfail, machinery.IS_QT5, "Fails on Qt 5"), ('qt6_xfail', pytest.mark.skipif, machinery.IS_QT6, "Fails on Qt 6"), ('qtwebkit_openssl3_skip', diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 134028012..dcc38ee10 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -695,7 +695,8 @@ class ImportFake: Attributes: modules: A dict mapping module names to bools. If True, the import will - succeed. Otherwise, it'll fail with ImportError. + succeed. If an exception is given, it will be raised. + Otherwise, it'll fail with a fake ImportError. version_attribute: The name to use in the fake modules for the version attribute. version: The version to use for the modules. @@ -727,6 +728,8 @@ class ImportFake: if name not in self.modules: # Not one of the modules to test -> use real import return None + elif isinstance(self.modules[name], Exception): + raise self.modules[name] elif self.modules[name]: ns = types.SimpleNamespace() if self.version_attribute is not None: diff --git a/tests/unit/test_qt_machinery.py b/tests/unit/test_qt_machinery.py new file mode 100644 index 000000000..53a715262 --- /dev/null +++ b/tests/unit/test_qt_machinery.py @@ -0,0 +1,448 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2023 Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <https://www.gnu.org/licenses/>. + +"""Test qutebrowser.qt.machinery.""" + +import re +import sys +import html +import argparse +import typing +from typing import Any, Optional, List, Dict, Union + +import pytest + +from qutebrowser.qt import machinery + + +# All global variables in machinery.py +MACHINERY_VARS = { + "USE_PYQT5", + "USE_PYQT6", + "USE_PYSIDE6", + "IS_QT5", + "IS_QT6", + "IS_PYQT", + "IS_PYSIDE", + "INFO", +} +# Make sure we didn't forget anything that's declared in the module. +# Not sure if this is a good idea. Might remove it in the future if it breaks. +assert set(typing.get_type_hints(machinery).keys()) == MACHINERY_VARS + + +@pytest.fixture(autouse=True) +def undo_init(monkeypatch: pytest.MonkeyPatch) -> None: + """Pretend Qt support isn't initialized yet and Qt was never imported.""" + monkeypatch.setattr(machinery, "_initialized", False) + monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False) + for wrapper in machinery.WRAPPERS: + monkeypatch.delitem(sys.modules, wrapper, raising=False) + for var in MACHINERY_VARS: + monkeypatch.delattr(machinery, var) + + +@pytest.mark.parametrize( + "exception", + [ + machinery.Unavailable(), + machinery.NoWrapperAvailableError(machinery.SelectionInfo()), + ], +) +def test_importerror_exceptions(exception: Exception): + with pytest.raises(ImportError): + raise exception + + +def test_selectioninfo_set_module_error(): + info = machinery.SelectionInfo() + info.set_module_error("PyQt5", ImportError("Python imploded")) + assert info == machinery.SelectionInfo( + wrapper=None, + reason=machinery.SelectionReason.unknown, + outcomes={"PyQt5": "ImportError: Python imploded"}, + ) + + +def test_selectioninfo_use_wrapper(): + info = machinery.SelectionInfo() + info.use_wrapper("PyQt6") + assert info == machinery.SelectionInfo( + wrapper="PyQt6", + reason=machinery.SelectionReason.unknown, + outcomes={"PyQt6": "success"}, + ) + + +@pytest.mark.parametrize( + "info, expected", + [ + ( + machinery.SelectionInfo( + wrapper="PyQt5", + reason=machinery.SelectionReason.cli, + ), + "Qt wrapper: PyQt5 (via --qt-wrapper)", + ), + ( + machinery.SelectionInfo( + wrapper="PyQt6", + reason=machinery.SelectionReason.env, + ), + "Qt wrapper: PyQt6 (via QUTE_QT_WRAPPER)", + ), + ( + machinery.SelectionInfo( + wrapper="PyQt6", + reason=machinery.SelectionReason.auto, + outcomes={ + "PyQt6": "success", + "PyQt5": "ImportError: Python imploded", + }, + ), + ( + "Qt wrapper info:\n" + " PyQt6: success\n" + " PyQt5: ImportError: Python imploded\n" + " -> selected: PyQt6 (via autoselect)" + ), + ), + ], +) +def test_selectioninfo_str(info: machinery.SelectionInfo, expected: str): + assert str(info) == expected + # The test is somewhat duplicating the logic here, but it's a good sanity check. + assert info.to_html() == html.escape(expected).replace("\n", "<br>") + + +@pytest.mark.parametrize("order", [["PyQt5", "PyQt6"], ["PyQt6", "PyQt5"]]) +def test_selectioninfo_str_wrapper_precedence(order: List[str]): + """The order of the wrappers should be the same as in machinery.WRAPPERS.""" + info = machinery.SelectionInfo( + wrapper="PyQt6", + reason=machinery.SelectionReason.auto, + ) + for module in order: + info.set_module_error(module, ImportError("Python imploded")) + + lines = str(info).splitlines()[1:-1] + wrappers = [line.split(":")[0].strip() for line in lines] + assert wrappers == machinery.WRAPPERS + + +@pytest.fixture +def modules(): + """Return a dict of modules to import-patch, all unavailable by default.""" + return dict.fromkeys(machinery.WRAPPERS, False) + + +@pytest.mark.parametrize( + "available, expected", + [ + pytest.param( + { + "PyQt5": ModuleNotFoundError("hiding somewhere"), + "PyQt6": ModuleNotFoundError("hiding somewhere"), + }, + machinery.SelectionInfo( + wrapper=None, + reason=machinery.SelectionReason.auto, + outcomes={ + "PyQt5": "ModuleNotFoundError: hiding somewhere", + "PyQt6": "ModuleNotFoundError: hiding somewhere", + }, + ), + id="none-available", + ), + pytest.param( + { + "PyQt5": ModuleNotFoundError("hiding somewhere"), + "PyQt6": True, + }, + machinery.SelectionInfo( + wrapper="PyQt6", + reason=machinery.SelectionReason.auto, + outcomes={"PyQt6": "success"}, + ), + id="only-pyqt6", + ), + pytest.param( + { + "PyQt5": True, + "PyQt6": ModuleNotFoundError("hiding somewhere"), + }, + machinery.SelectionInfo( + wrapper="PyQt5", + reason=machinery.SelectionReason.auto, + outcomes={ + "PyQt6": "ModuleNotFoundError: hiding somewhere", + "PyQt5": "success", + }, + ), + id="only-pyqt5", + ), + pytest.param( + {"PyQt5": True, "PyQt6": True}, + machinery.SelectionInfo( + wrapper="PyQt6", + reason=machinery.SelectionReason.auto, + outcomes={"PyQt6": "success"}, + ), + id="both", + ), + pytest.param( + { + "PyQt6": ImportError("Fake ImportError for PyQt6."), + "PyQt5": True, + }, + machinery.SelectionInfo( + wrapper=None, + reason=machinery.SelectionReason.auto, + outcomes={ + "PyQt6": "ImportError: Fake ImportError for PyQt6.", + } + ), + id="import-error", + ), + ], +) +def test_autoselect( + stubs: Any, + available: Dict[str, Union[bool, Exception]], + expected: machinery.SelectionInfo, + monkeypatch: pytest.MonkeyPatch, +): + stubs.ImportFake(available, monkeypatch).patch() + assert machinery._autoselect_wrapper() == expected + + +@pytest.mark.parametrize( + "args, env, expected", + [ + # Defaults with no overrides + ( + None, + None, + machinery.SelectionInfo( + wrapper="PyQt5", reason=machinery.SelectionReason.default + ), + ), + ( + argparse.Namespace(qt_wrapper=None), + None, + machinery.SelectionInfo( + wrapper="PyQt5", reason=machinery.SelectionReason.default + ), + ), + ( + argparse.Namespace(qt_wrapper=None), + "", + machinery.SelectionInfo( + wrapper="PyQt5", reason=machinery.SelectionReason.default + ), + ), + # Only argument given + ( + argparse.Namespace(qt_wrapper="PyQt6"), + None, + machinery.SelectionInfo( + wrapper="PyQt6", reason=machinery.SelectionReason.cli + ), + ), + ( + argparse.Namespace(qt_wrapper="PyQt5"), + None, + machinery.SelectionInfo( + wrapper="PyQt5", reason=machinery.SelectionReason.cli + ), + ), + ( + argparse.Namespace(qt_wrapper="PyQt5"), + "", + machinery.SelectionInfo( + wrapper="PyQt5", reason=machinery.SelectionReason.cli + ), + ), + # Only environment variable given + ( + None, + "PyQt6", + machinery.SelectionInfo( + wrapper="PyQt6", reason=machinery.SelectionReason.env + ), + ), + ( + None, + "PyQt5", + machinery.SelectionInfo( + wrapper="PyQt5", reason=machinery.SelectionReason.env + ), + ), + # Both given + ( + argparse.Namespace(qt_wrapper="PyQt5"), + "PyQt6", + machinery.SelectionInfo( + wrapper="PyQt5", reason=machinery.SelectionReason.cli + ), + ), + ( + argparse.Namespace(qt_wrapper="PyQt6"), + "PyQt5", + machinery.SelectionInfo( + wrapper="PyQt6", reason=machinery.SelectionReason.cli + ), + ), + ( + argparse.Namespace(qt_wrapper="PyQt6"), + "PyQt6", + machinery.SelectionInfo( + wrapper="PyQt6", reason=machinery.SelectionReason.cli + ), + ), + ], +) +def test_select_wrapper( + args: Optional[argparse.Namespace], + env: Optional[str], + expected: machinery.SelectionInfo, + monkeypatch: pytest.MonkeyPatch, + undo_init: None, +): + if env is None: + monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False) + else: + monkeypatch.setenv("QUTE_QT_WRAPPER", env) + + assert machinery._select_wrapper(args) == expected + + +def test_select_wrapper_after_qt_import(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setitem(sys.modules, "PyQt6", None) + with pytest.warns(UserWarning, match="PyQt6 already imported"): + machinery._select_wrapper(args=None) + + +class TestInit: + @pytest.fixture + def empty_args(self) -> argparse.Namespace: + return argparse.Namespace(qt_wrapper=None) + + def test_multiple_implicit(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(machinery, "_initialized", True) + machinery.init_implicit() + machinery.init_implicit() + + def test_multiple_explicit( + self, + monkeypatch: pytest.MonkeyPatch, + empty_args: argparse.Namespace, + ): + monkeypatch.setattr(machinery, "_initialized", True) + + with pytest.raises( + machinery.Error, match=r"init\(\) already called before application init" + ): + machinery.init(args=empty_args) + + def test_none_available_implicit( + self, + stubs: Any, + modules: Dict[str, bool], + monkeypatch: pytest.MonkeyPatch, + undo_init: None, + ): + # FIXME:qt6 Also try without this once auto is default + monkeypatch.setenv("QUTE_QT_WRAPPER", "auto") + stubs.ImportFake(modules, monkeypatch).patch() + + message_lines = [ + "No Qt wrapper was importable.", + "", + "Qt wrapper info:", + " PyQt6: ImportError: Fake ImportError for PyQt6.", + " PyQt5: not imported", + " -> selected: None (via autoselect)", + ] + + with pytest.raises( + machinery.NoWrapperAvailableError, + match=re.escape("\n".join(message_lines)), + ): + machinery.init_implicit() + + def test_none_available_explicit( + self, + stubs: Any, + modules: Dict[str, bool], + monkeypatch: pytest.MonkeyPatch, + empty_args: argparse.Namespace, + undo_init: None, + ): + # FIXME:qt6 Also try without this once auto is default + monkeypatch.setenv("QUTE_QT_WRAPPER", "auto") + stubs.ImportFake(modules, monkeypatch).patch() + + info = machinery.init(args=empty_args) + assert info == machinery.SelectionInfo( + wrapper=None, + reason=machinery.SelectionReason.auto, + outcomes={ + "PyQt6": "ImportError: Fake ImportError for PyQt6.", + } + ) + + @pytest.mark.parametrize( + "selected_wrapper, true_vars", + [ + ("PyQt6", ["USE_PYQT6", "IS_QT6", "IS_PYQT"]), + ("PyQt5", ["USE_PYQT5", "IS_QT5", "IS_PYQT"]), + ("PySide6", ["USE_PYSIDE6", "IS_QT6", "IS_PYSIDE"]), + ], + ) + @pytest.mark.parametrize("explicit", [True, False]) + def test_properly( + self, + monkeypatch: pytest.MonkeyPatch, + selected_wrapper: str, + true_vars: str, + explicit: bool, + empty_args: argparse.Namespace, + undo_init: None, + ): + info = machinery.SelectionInfo( + wrapper=selected_wrapper, + reason=machinery.SelectionReason.fake, + ) + monkeypatch.setattr(machinery, "_select_wrapper", lambda args: info) + + if explicit: + ret = machinery.init(empty_args) + assert ret == info + else: + machinery.init_implicit() + + assert machinery.INFO == info + + bool_vars = MACHINERY_VARS - {"INFO"} + expected_vars = dict.fromkeys(bool_vars, False) + expected_vars.update(dict.fromkeys(true_vars, True)) + actual_vars = {var: getattr(machinery, var) for var in bool_vars} + + assert expected_vars == actual_vars diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 056cd8e4c..fe48f9fe7 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -33,6 +33,7 @@ import dataclasses import pytest import hypothesis import hypothesis.strategies +from qutebrowser.qt import machinery from qutebrowser.qt.core import PYQT_VERSION_STR import qutebrowser @@ -1269,6 +1270,10 @@ def test_version_info(params, stubs, monkeypatch, config_stub): 'sql.version': lambda: 'SQLITE VERSION', '_uptime': lambda: datetime.timedelta(hours=1, minutes=23, seconds=45), 'config.instance.yaml_loaded': params.autoconfig_loaded, + 'machinery.INFO': machinery.SelectionInfo( + wrapper="QT WRAPPER", + reason=machinery.SelectionReason.fake + ), } version.opengl_info.cache_clear() @@ -1340,6 +1345,8 @@ def test_version_info(params, stubs, monkeypatch, config_stub): PYTHON IMPLEMENTATION: PYTHON VERSION PyQt: PYQT VERSION + Qt wrapper: QT WRAPPER (via fake) + MODULE VERSION 1 MODULE VERSION 2 pdf.js: PDFJS VERSION @@ -215,8 +215,9 @@ passenv = TERM MYPY_FORCE_TERMINAL_WIDTH setenv = - pyqt6: QUTE_CONSTANTS_ARGS=--always-true=USE_PYQT6 --always-false=USE_PYQT5 --always-false=USE_PYSIDE2 --always-false=USE_PYSIDE6 --always-false=IS_QT5 --always-true=IS_QT6 - pyqt5: QUTE_CONSTANTS_ARGS=--always-false=USE_PYQT6 --always-true=USE_PYQT5 --always-false=USE_PYSIDE2 --always-false=USE_PYSIDE6 --always-true=IS_QT5 --always-false=IS_QT6 + # See qutebrowser/qt/machinery.py + pyqt6: QUTE_CONSTANTS_ARGS=--always-true=USE_PYQT6 --always-false=USE_PYQT5 --always-false=USE_PYSIDE6 --always-false=IS_QT5 --always-true=IS_QT6 --always-true=IS_PYQT --always-false=IS_PYSIDE + pyqt5: QUTE_CONSTANTS_ARGS=--always-false=USE_PYQT6 --always-true=USE_PYQT5 --always-false=USE_PYSIDE6 --always-true=IS_QT5 --always-false=IS_QT6 --always-true=IS_PYQT --always-false=IS_PYSIDE deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-dev.txt |