diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6093d2c..b72d14e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,11 @@ name: build on: [push, pull_request] +# Cancel running jobs for the same branch and workflow. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: diff --git a/.gitignore b/.gitignore index 2e797d0..6610177 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ src/pytest_qt.egg-info # auto-generated by setuptools_scm /src/pytestqt/_version.py + +# pycharm +.idea diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d12cf5..d3a40fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: rev: 1.13.0 hooks: - id: blacken-docs - additional_dependencies: [black==20.8b1] + additional_dependencies: [black>=22.1.0] language_version: python3 - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 03e638b..0d415bc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,12 @@ +4.3.0 (UNRELEASED) +------------------ + +- New ``qmlbot`` fixture to help test QtQuick applications (`#476`_). Thanks `@nrbnlulu`_ for the PR. + +.. _#476: https://github.com/pytest-dev/pytest-qt/pull/476 +.. _@nrbnlulu: https://github.com/nrbnlulu + + 4.2.0 (2022-10-25) ------------------ diff --git a/docs/index.rst b/docs/index.rst index 8056cbd..6e0ce34 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ pytest-qt virtual_methods modeltester qapplication + qmlbot note_dialogs debugging troubleshooting diff --git a/docs/qmlbot.rst b/docs/qmlbot.rst new file mode 100644 index 0000000..f0ff772 --- /dev/null +++ b/docs/qmlbot.rst @@ -0,0 +1,36 @@ +========= +qmlbot +========= + +Fixture that helps interacting with QML. + +Example - load qml from string: + +.. code-block:: python + + def test_say_hello(qmlbot): + qml = """ + import QtQuick 2.0 + + Rectangle{ + objectName: "sample"; + property string hello: "world" + } + """ + item = qmlbot.loads(qml) + assert item.property("hello") == "world" + + +Example - load qml from file: + +.. code-block:: python + + from pathlib import Path + + + def test_say_hello(qmlbot): + item = qmlbot.load(Path("sayhello.qml")) + assert item.property("hello") == "world" + +Note: if your components depends on any instances or ``@QmlElement``'s you need +to make sure it is acknowledge by ``qmlbot.engine`` diff --git a/setup.py b/setup.py index a33eabf..20d9a9a 100644 --- a/setup.py +++ b/setup.py @@ -8,6 +8,8 @@ packages=find_packages(where="src"), package_dir={"": "src"}, entry_points={"pytest11": ["pytest-qt = pytestqt.plugin"]}, + include_package_data=True, + package_data={"pytestqt": ["**/*.qml"]}, install_requires=["pytest>=3.0.0"], extras_require={ "doc": ["sphinx", "sphinx_rtd_theme"], diff --git a/src/pytestqt/__init__.py b/src/pytestqt/__init__.py index 7c6237c..d03f03a 100644 --- a/src/pytestqt/__init__.py +++ b/src/pytestqt/__init__.py @@ -1,4 +1,8 @@ # _version is automatically generated by setuptools_scm from pytestqt._version import version +from .qml.qmlbot import QmlBot + __version__ = version + +__all__ = ["QmlBot", "__version__"] diff --git a/src/pytestqt/exceptions.py b/src/pytestqt/exceptions.py index 980f9ac..e8bcec0 100644 --- a/src/pytestqt/exceptions.py +++ b/src/pytestqt/exceptions.py @@ -79,7 +79,7 @@ def format_captured_exceptions(exceptions): stream.write("Exceptions caught in Qt event loop:\n") sep = "_" * 80 + "\n" stream.write(sep) - for (exc_type, value, tback) in exceptions: + for exc_type, value, tback in exceptions: traceback.print_exception(exc_type, value, tback, file=stream) stream.write(sep) return stream.getvalue() diff --git a/src/pytestqt/modeltest.py b/src/pytestqt/modeltest.py index af87745..b2df3fe 100644 --- a/src/pytestqt/modeltest.py +++ b/src/pytestqt/modeltest.py @@ -54,7 +54,6 @@ class _ChangeInFlight(enum.Enum): - COLUMNS_INSERTED = enum.auto() COLUMNS_MOVED = enum.auto() COLUMNS_REMOVED = enum.auto() diff --git a/src/pytestqt/plugin.py b/src/pytestqt/plugin.py index fab2c72..2ff7468 100644 --- a/src/pytestqt/plugin.py +++ b/src/pytestqt/plugin.py @@ -7,6 +7,7 @@ _QtExceptionCaptureManager, ) from pytestqt.logging import QtLoggingPlugin, _QtMessageCapture +from pytestqt.qml.qmlbot import QmlBot from pytestqt.qt_compat import qt_api from pytestqt.qtbot import QtBot, _close_widgets @@ -93,6 +94,11 @@ def qtbot(qapp, request): return result +@pytest.fixture +def qmlbot(qapp) -> QmlBot: + return QmlBot() + + @pytest.fixture def qtlog(request): """Fixture that can access messages captured during testing""" diff --git a/src/pytestqt/qml/__init__.py b/src/pytestqt/qml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pytestqt/qml/botloader.qml b/src/pytestqt/qml/botloader.qml new file mode 100644 index 0000000..7ff9d64 --- /dev/null +++ b/src/pytestqt/qml/botloader.qml @@ -0,0 +1,18 @@ +import QtQuick 2.15 +import QtQuick.Window 2.15 + +Window { + id: root + width: 500 + height: 400 + visible: true + + + Item { + anchors.fill: parent + Loader { + objectName: "contentloader" + source: "" + } + } +} diff --git a/src/pytestqt/qml/qmlbot.py b/src/pytestqt/qml/qmlbot.py new file mode 100644 index 0000000..0a71a9b --- /dev/null +++ b/src/pytestqt/qml/qmlbot.py @@ -0,0 +1,47 @@ +import os +from pathlib import Path +from typing import Any + +from pytestqt.qt_compat import qt_api + + +class QmlBot: + def __init__(self) -> None: + self.engine = qt_api.QtQml.QQmlApplicationEngine() + main = Path(__file__).parent / "botloader.qml" + self.engine.load(os.fspath(main)) + + @property + def _loader(self) -> Any: + self._root = self.engine.rootObjects()[ + 0 + ] # self is needed for it not to be collected by the gc + return self._root.findChild(qt_api.QtQuick.QQuickItem, "contentloader") + + def load(self, path: Path) -> Any: + """ + :returns: `QQuickItem` - the initialized component + """ + self._loader.setProperty( + "source", qt_api.QtCore.QUrl(path.resolve(True).as_uri()) + ) + return self._loader.property("item") + + def loads(self, content: str) -> Any: + """ + :returns: `QQuickItem` - the initialized component + """ + self._comp = qt_api.QtQml.QQmlComponent( + self.engine + ) # needed for it not to be collected by the gc + self._comp.setData(content.encode("utf-8"), qt_api.QtCore.QUrl()) + if self._comp.status() != qt_api.QtQml.QQmlComponent.Status.Ready: + raise RuntimeError( + f"component {self._comp} is not Ready:\n" + f"STATUS: {self._comp.status()}\n" + f"HINT: make sure there are no wrong spaces.\n" + f"ERRORS: {self._comp.errors()}" + ) + self._loader.setProperty("source", "") + self._loader.setProperty("sourceComponent", self._comp) + return self._loader.property("item") diff --git a/src/pytestqt/qt_compat.py b/src/pytestqt/qt_compat.py index e0c3a35..9d0bfc9 100644 --- a/src/pytestqt/qt_compat.py +++ b/src/pytestqt/qt_compat.py @@ -110,6 +110,8 @@ def _import_module(module_name): self.QtGui = _import_module("QtGui") self.QtTest = _import_module("QtTest") self.QtWidgets = _import_module("QtWidgets") + self.QtQml = _import_module("QtQml") + self.QtQuick = _import_module("QtQuick") self._check_qt_api_version() diff --git a/tests/test_basics.py b/tests/test_basics.py index e14fd65..428d121 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -592,7 +592,6 @@ def _fake_is_library_loaded(name, *args): ], ) def test_already_loaded_backend(monkeypatch, option_api, backend): - import builtins class Mock: @@ -628,6 +627,8 @@ class Mock: qbackend.QtCore = qtcore qbackend.QtGui = object() qbackend.QtTest = object() + qbackend.QtQml = object() + qbackend.QtQuick = object() qbackend.QtWidgets = qtwidgets import_orig = builtins.__import__ diff --git a/tests/test_qml/__init__.py b/tests/test_qml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_qml/sample.qml b/tests/test_qml/sample.qml new file mode 100644 index 0000000..8a2b3d4 --- /dev/null +++ b/tests/test_qml/sample.qml @@ -0,0 +1,6 @@ +import QtQuick 2.0 + +Rectangle{ + objectName: "sample"; + property string hello: "world" +} diff --git a/tests/test_qml/test_qmlbot.py b/tests/test_qml/test_qmlbot.py new file mode 100644 index 0000000..2a020b4 --- /dev/null +++ b/tests/test_qml/test_qmlbot.py @@ -0,0 +1,34 @@ +from pathlib import Path +from textwrap import dedent + +import pytest + +from pytestqt import QmlBot + + +def test_load_from_string_wrong_syntax(qmlbot: QmlBot) -> None: + qml = "import QtQuick 2.0 Rectangle{" + with pytest.raises(RuntimeError): + qmlbot.loads(qml) + + +def test_load_from_string(qmlbot: QmlBot) -> None: + text = "that's a template!" + qml = dedent( + """ + import QtQuick 2.0 + + Rectangle{ + objectName: "sample"; + property string hello: "%s" + } + """ + % text + ) + item = qmlbot.loads(qml) + assert item.property("hello") == text + + +def test_load_from_file(qmlbot: QmlBot) -> None: + item = qmlbot.load(Path(__file__).parent / "sample.qml") + assert item.property("hello") == "world"