Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add qmlbot #476

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ src/pytest_qt.egg-info

# auto-generated by setuptools_scm
/src/pytestqt/_version.py

# pycharm
.idea
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
------------------

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pytest-qt
virtual_methods
modeltester
qapplication
qmlbot
note_dialogs
debugging
troubleshooting
Expand Down
36 changes: 36 additions & 0 deletions docs/qmlbot.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
=========
qmlbot
=========

Fixture that helps interacting with QML.
nrbnlulu marked this conversation as resolved.
Show resolved Hide resolved

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``
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
4 changes: 4 additions & 0 deletions src/pytestqt/__init__.py
Original file line number Diff line number Diff line change
@@ -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__"]
6 changes: 6 additions & 0 deletions src/pytestqt/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"""
Expand Down
Empty file added src/pytestqt/qml/__init__.py
Empty file.
18 changes: 18 additions & 0 deletions src/pytestqt/qml/botloader.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import QtQuick 2.15
nrbnlulu marked this conversation as resolved.
Show resolved Hide resolved
import QtQuick.Window 2.15

Window {
id: root
width: 500
height: 400
visible: true


Item {
anchors.fill: parent
Loader {
objectName: "contentloader"
source: ""
}
}
}
38 changes: 38 additions & 0 deletions src/pytestqt/qml/qmlbot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import os
from pathlib import Path
from typing import Any

from pytestqt.qt_compat import qt_api


class QmlBot:
nrbnlulu marked this conversation as resolved.
Show resolved Hide resolved
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 loads(self, content: str) -> Any:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI decided to drop the load(path: Path) -> Any variant, because the user can easily just call loads(p.read_text(encoding=...)) instead of load(p), with the advantage that we do not need to guess an encoding.

Copy link
Author

@nrbnlulu nrbnlulu Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, that is not the same. qml files can import other qml files based on relative path.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow, what does the fact that "qml files can import other qml files" relates to being able to load a qml file passing a Path? Can you exemplify?

Feel free to add it back, I removed because it seemed redundant and it the test was failing, but if it is not redundant we can revisit this.

Copy link
Author

@nrbnlulu nrbnlulu Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could though pass the pass in the url here self._comp.setData(content.encode("utf-8"), qt_api.QtCore.QUrl()) I think
though I don't know if it is really the same under the hood. this way or another still worth to keep.

Copy link
Author

@nrbnlulu nrbnlulu Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In QML you can every file encapsulates a Component i.e if MyApp.qml lives near main.qml
You would do

import QtQuick

MyApp{}

and if i.e MyApp.qml resides in ./impl/MyApp.qml so you would need to import impl

import QtQuick
import "impl"

MyApp{}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh on CI it is showing the same error I got locally:

_____________________________ test_load_from_file _____________________________

qmlbot = <pytestqt.qml.qmlbot.QmlBot object at 0x00000193133134C8>

    def test_load_from_file(qmlbot: QmlBot) -> None:
        item = qmlbot.load(Path(__file__).parent / "sample.qml")
>       assert item.property("hello") == "world"
E       AttributeError: 'NoneType' object has no attribute 'property'

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you on windows?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. 👍

"""
: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")
2 changes: 2 additions & 0 deletions src/pytestqt/qt_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,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__
Expand Down
Empty file added tests/test_qml/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions tests/test_qml/sample.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import QtQuick 2.0

Rectangle{
objectName: "sample";
property string hello: "world"
}
28 changes: 28 additions & 0 deletions tests/test_qml/test_qmlbot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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