summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Bruhin <me@the-compiler.org>2023-06-14 20:04:52 +0200
committerGitHub <noreply@github.com>2023-06-14 20:04:52 +0200
commite8ed81d9e505fbc13378d920418c0bbb20a5443d (patch)
tree996dfb30edc987043bbee5b0003fb6729d0e6314
parent4bf1e69783c19195fe5a6e43474d274e35c3cfa8 (diff)
parentd6af5394542a96818ef8389227c7e4020f1c5f9f (diff)
downloadqutebrowser-e8ed81d9e505fbc13378d920418c0bbb20a5443d.tar.gz
qutebrowser-e8ed81d9e505fbc13378d920418c0bbb20a5443d.zip
Merge pull request #7741 from qutebrowser/better-qt-wrappers
Resolve remaining Qt wrapper selection UX issues
-rw-r--r--.flake81
-rw-r--r--qutebrowser/misc/backendproblem.py40
-rw-r--r--qutebrowser/misc/earlyinit.py48
-rw-r--r--qutebrowser/qt/core.py17
-rw-r--r--qutebrowser/qt/dbus.py17
-rw-r--r--qutebrowser/qt/gui.py17
-rw-r--r--qutebrowser/qt/machinery.py296
-rw-r--r--qutebrowser/qt/network.py17
-rw-r--r--qutebrowser/qt/opengl.py17
-rw-r--r--qutebrowser/qt/printsupport.py17
-rw-r--r--qutebrowser/qt/qml.py17
-rw-r--r--qutebrowser/qt/sip.py36
-rw-r--r--qutebrowser/qt/sql.py17
-rw-r--r--qutebrowser/qt/test.py17
-rw-r--r--qutebrowser/qt/webenginecore.py17
-rw-r--r--qutebrowser/qt/webenginewidgets.py17
-rw-r--r--qutebrowser/qt/webkit.py20
-rw-r--r--qutebrowser/qt/webkitwidgets.py18
-rw-r--r--qutebrowser/qt/widgets.py17
-rw-r--r--qutebrowser/qutebrowser.py6
-rw-r--r--qutebrowser/utils/version.py2
-rwxr-xr-xscripts/dev/run_vulture.py3
-rw-r--r--tests/conftest.py4
-rw-r--r--tests/helpers/stubs.py5
-rw-r--r--tests/unit/test_qt_machinery.py448
-rw-r--r--tests/unit/utils/test_version.py7
-rw-r--r--tox.ini5
27 files changed, 1005 insertions, 138 deletions
diff --git a/.flake8 b/.flake8
index 8838a6990..8460fa68d 100644
--- a/.flake8
+++ b/.flake8
@@ -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
diff --git a/tox.ini b/tox.ini
index 606446f70..9dc56737d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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