qt: Split machinery.init() and init_explicit() into two functions

This also moves the checking for sys.modules into _select_wrapper.
This commit is contained in:
Florian Bruhin 2023-06-13 17:01:15 +02:00
parent 322834d0e6
commit 63e96fa3fe
17 changed files with 192 additions and 156 deletions

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtcore-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtdbus-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtgui-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -139,6 +139,11 @@ def _select_wrapper(args: Optional[argparse.Namespace]) -> SelectionInfo:
- Otherwise, if the QUTE_QT_WRAPPER environment variable is set, use that.
- Otherwise, use PyQt5 (FIXME:qt6 autoselect).
"""
for name in WRAPPERS:
# If any Qt wrapper has been imported before this, all hope is lost.
if name in sys.modules:
raise Error(f"{name} already imported")
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)
@ -190,56 +195,24 @@ IS_PYSIDE: bool
_initialized = False
def init(args: Optional[argparse.Namespace] = None) -> SelectionInfo:
"""Initialize Qt wrapper globals.
def _set_globals(info: SelectionInfo) -> None:
"""Set all global variables in this module based on the given SelectionInfo.
There is two ways how this function can be called:
- Explicitly, during qutebrowser startup, where it gets called before
earlyinit.early_init() in qutebrowser.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).
- Implicitly, when any of the qutebrowser.qt.* modules in this package is imported.
This should never happen during normal qutebrowser usage, but means 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.
In this case, `args` will be None.
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
if args is None:
# Implicit initialization can happen multiple times
# (all subsequent calls are a no-op)
if _initialized:
return None # FIXME:qt6
else:
# Explicit initialization can happen exactly once, and if it's used, there
# should not be any implicit initialization (qutebrowser.qt imports) before it.
if _initialized: # pylint: disable=else-if-used
raise Error("init() already called before application init")
assert info.wrapper is not None, info
assert not _initialized
_initialized = True
for name in WRAPPERS:
# If any Qt wrapper has been imported before this, all hope is lost.
if name in sys.modules:
raise Error(f"{name} already imported")
INFO = _select_wrapper(args)
if INFO.wrapper is None:
# No Qt wrapper was importable.
if args is None:
# Implicit initialization -> raise error immediately
raise NoWrapperAvailableError(INFO)
else:
# Explicit initialization -> show error in earlyinit.py
return INFO
USE_PYQT5 = INFO.wrapper == "PyQt5"
USE_PYQT6 = INFO.wrapper == "PyQt6"
USE_PYSIDE6 = INFO.wrapper == "PySide6"
assert USE_PYQT5 ^ USE_PYQT6 ^ USE_PYSIDE6
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
@ -248,4 +221,49 @@ def init(args: Optional[argparse.Namespace] = None) -> SelectionInfo:
assert IS_QT5 ^ IS_QT6
assert IS_PYQT ^ IS_PYSIDE
return INFO
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 None
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 is None here (no Qt wrapper available), we'll show an error later
# in earlyinit.py.
_set_globals(info)
return info

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtnetwork-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtopengl-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtprintsupport-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtqml-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -16,7 +16,7 @@ Note that we don't yet abstract between PySide/PyQt here.
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6: # pylint: disable=no-else-raise
raise machinery.Unavailable()

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtsql-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qttest-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtwebenginecore-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtwebenginewidgets-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -15,7 +15,7 @@ https://qtwebkit.github.io/doc/qtwebkit/qtwebkit-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6: # pylint: disable=no-else-raise

View File

@ -15,7 +15,7 @@ https://qtwebkit.github.io/doc/qtwebkit/qtwebkitwidgets-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -14,7 +14,7 @@ https://doc.qt.io/qt-6/qtwidgets-index.html
from qutebrowser.qt import machinery
machinery.init()
machinery.init_implicit()
if machinery.USE_PYSIDE6:

View File

@ -29,6 +29,15 @@ import pytest
from qutebrowser.qt import machinery
@pytest.fixture
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)
@pytest.mark.parametrize(
"exception",
[
@ -81,9 +90,10 @@ def test_selectioninfo_set_module():
"PyQt5: ImportError: Python imploded\n"
"PyQt6: success\n"
"selected: PyQt6 (via autoselect)"
)
),
),
])
],
)
def test_selectioninfo_str(info: machinery.SelectionInfo, expected: str):
assert str(info) == expected
@ -236,6 +246,7 @@ def test_select_wrapper(
env: Optional[str],
expected: machinery.SelectionInfo,
monkeypatch: pytest.MonkeyPatch,
undo_init: None,
):
if env is None:
monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False)
@ -245,109 +256,116 @@ def test_select_wrapper(
assert machinery._select_wrapper(args) == expected
@pytest.fixture
def undo_init(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pretend Qt support isn't initialized yet and Qt was never imported."""
for wrapper in machinery.WRAPPERS:
monkeypatch.delitem(sys.modules, wrapper, raising=False)
monkeypatch.setattr(machinery, "_initialized", False)
monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False)
def test_init_multiple_implicit(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(machinery, "_initialized", True)
machinery.init()
machinery.init()
def test_init_multiple_explicit(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(machinery, "_initialized", True)
machinery.init()
with pytest.raises(
machinery.Error, match=r"init\(\) already called before application init"
):
machinery.init(args=argparse.Namespace(qt_wrapper="PyQt6"))
def test_init_after_qt_import(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(machinery, "_initialized", False)
def test_select_wrapper_after_qt_import():
assert any(wrapper in sys.modules for wrapper in machinery.WRAPPERS)
with pytest.raises(machinery.Error, match="Py.* already imported"):
machinery.init()
machinery._select_wrapper(args=None)
@pytest.mark.xfail(reason="autodetect not used yet")
def test_init_none_available_implicit(
stubs: Any,
modules: Dict[str, bool],
monkeypatch: pytest.MonkeyPatch,
undo_init: None,
):
stubs.ImportFake(modules, monkeypatch).patch()
message = "No Qt wrapper was importable." # FIXME maybe check info too
with pytest.raises(machinery.NoWrapperAvailableError, match=message):
machinery.init(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()
@pytest.mark.xfail(reason="autodetect not used yet")
def test_init_none_available_explicit(
stubs: Any,
modules: Dict[str, bool],
monkeypatch: pytest.MonkeyPatch,
undo_init: None,
):
stubs.ImportFake(modules, monkeypatch).patch()
info = machinery.init(args=argparse.Namespace(qt_wrapper=None))
assert info == machinery.SelectionInfo(
wrapper=None,
reason=machinery.SelectionReason.default,
pyqt6="ImportError: Fake ImportError for PyQt6.",
pyqt5="ImportError: Fake ImportError for PyQt5.",
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)
@pytest.mark.xfail(reason="autodetect not used yet")
def test_none_available_implicit(
self,
stubs: Any,
modules: Dict[str, bool],
monkeypatch: pytest.MonkeyPatch,
undo_init: None,
):
stubs.ImportFake(modules, monkeypatch).patch()
message = "No Qt wrapper was importable." # FIXME maybe check info too
with pytest.raises(machinery.NoWrapperAvailableError, match=message):
machinery.init_implicit()
@pytest.mark.xfail(reason="autodetect not used yet")
def test_none_available_explicit(
self,
stubs: Any,
modules: Dict[str, bool],
monkeypatch: pytest.MonkeyPatch,
empty_args: argparse.Namespace,
undo_init: None,
):
stubs.ImportFake(modules, monkeypatch).patch()
info = machinery.init(args=empty_args)
assert info == machinery.SelectionInfo(
wrapper=None,
reason=machinery.SelectionReason.default,
pyqt6="ImportError: Fake ImportError for PyQt6.",
pyqt5="ImportError: Fake ImportError for PyQt5.",
)
@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,
):
bool_vars = [
"USE_PYQT5",
"USE_PYQT6",
"USE_PYSIDE6",
"IS_QT5",
"IS_QT6",
"IS_PYQT",
"IS_PYSIDE",
]
all_vars = bool_vars + ["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()) == set(all_vars)
for var in all_vars:
monkeypatch.delattr(machinery, var)
@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"]),
],
)
def test_init_properly(
monkeypatch: pytest.MonkeyPatch,
selected_wrapper: str,
true_vars: str,
undo_init: None,
):
bool_vars = [
"USE_PYQT5",
"USE_PYQT6",
"USE_PYSIDE6",
"IS_QT5",
"IS_QT6",
"IS_PYQT",
"IS_PYSIDE",
]
all_vars = bool_vars + ["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()) == set(all_vars)
info = machinery.SelectionInfo(
wrapper=selected_wrapper,
reason=machinery.SelectionReason.fake,
)
monkeypatch.setattr(machinery, "_select_wrapper", lambda args: info)
for var in all_vars:
monkeypatch.delattr(machinery, var)
if explicit:
ret = machinery.init(empty_args)
assert ret == info
else:
machinery.init_implicit()
info = machinery.SelectionInfo(
wrapper=selected_wrapper,
reason=machinery.SelectionReason.fake,
)
monkeypatch.setattr(machinery, "_select_wrapper", lambda args: info)
assert machinery.INFO == info
machinery.init()
assert machinery.INFO == 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}
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
assert expected_vars == actual_vars