summaryrefslogtreecommitdiff
path: root/qutebrowser/qt/machinery.py
blob: 45a1f659819124aa571657d81fcee50c4f696754 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# pyright: reportConstantRedefinition=false

"""Qt wrapper selection.

Contains selection logic and globals for Qt wrapper selection.

All other files in this package are intended to be simple wrappers around Qt imports.
Depending on what is set in this module, they import from PyQt5 or PyQt6.

The import wrappers are intended to be as thin as possible. They will not unify
API-level differences between Qt 5 and Qt 6. This is best handled by the calling code,
which has a better picture of what changed between APIs and how to best handle it.

What they *will* do is handle simple 1:1 renames of classes, or moves between
modules (where they aim to always expose the Qt 6 API). See e.g. webenginecore.py.
"""

# 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

from qutebrowser.utils import log

# Packagers: Patch the line below to enforce a Qt wrapper, e.g.:
# sed -i 's/_WRAPPER_OVERRIDE = .*/_WRAPPER_OVERRIDE = "PyQt6"/' qutebrowser/qt/machinery.py
#
# Users: Set the QUTE_QT_WRAPPER environment variable to change the default wrapper.
_WRAPPER_OVERRIDE = None  # type: ignore[var-annotated]

WRAPPERS = [
    "PyQt6",
    "PyQt5",
    # Needs more work
    # "PySide6",
]


class Error(Exception):
    """Base class for all exceptions in this module."""


class Unavailable(Error, ModuleNotFoundError):

    """Raised when a module is unavailable with the given wrapper."""

    def __init__(self) -> None:
        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):
    """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 wrapper was overridden by patching _WRAPPER_OVERRIDE.
    override = "override"

    #: The reason was not set.
    unknown = "unknown"


@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 ModuleNotFoundError as e:
            # Wrapper not available -> try the next one.
            info.set_module_error(wrapper, e)
            continue
        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.

    - If --qt-wrapper is given, use that.
    - Otherwise, if the QUTE_QT_WRAPPER environment variable is set, use that.
    - Otherwise, try the wrappers in WRAPPER in order (PyQt6 -> PyQt5)
    """
    # If any Qt wrapper has been imported before this, something strange might
    # be happening. With PyInstaller, it imports the Qt bindings early.
    for name in WRAPPERS:
        if name in sys.modules and not hasattr(sys, "frozen"):
            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)

    env_var = "QUTE_QT_WRAPPER"
    env_wrapper = os.environ.get(env_var)
    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)

    if _WRAPPER_OVERRIDE is not None:
        assert _WRAPPER_OVERRIDE in WRAPPERS
        return SelectionInfo(wrapper=_WRAPPER_OVERRIDE, reason=SelectionReason.override)

    return _autoselect_wrapper()


# 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)
        log.init.debug(str(info))

    # If info is None here (no Qt wrapper available), we'll show an error later
    # in earlyinit.py.

    return info