diff --git a/.gitignore b/.gitignore
index 4309e5a..d427f94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,3 +72,6 @@ target/
# pyenv
.python-version
+/tests/data/db.gpkg-shm
+/tests/data/db.gpkg-wal
+/tests/data/*.tif.aux.xml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3204d02..39765e3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -28,13 +28,3 @@ repos:
- flake8-annotations~=2.6.2
- flake8-qgis>=0.1.4
- flake8-pytest-style==1.5.0
- - repo: https://github.com/jazzband/pip-tools
- rev: 6.4.0
- hooks:
- - id: pip-compile
- name: pip-compile setup.py
- files: ^(setup\.py|requirements\.txt)$
- - id: pip-compile
- name: pip-compile requirements-dev.in
- args: [requirements-dev.in]
- files: ^requirements-dev\.(in|txt)$
diff --git a/README.md b/README.md
index 6910758..d4b66eb 100644
--- a/README.md
+++ b/README.md
@@ -20,11 +20,51 @@ This plugin makes it easier to write QGIS plugin tests with the help of some fix
configured [`QgsApplication`](https://qgis.org/pyqgis/master/core/QgsApplication.html). This fixture is called
automatically on the start of pytest session.
* `qgis_canvas` returns [`QgsMapCanvas`](https://qgis.org/pyqgis/master/gui/QgsMapCanvas.html).
+* `qgis_parent` returns the QWidget used as parent of the `qgis_canvas`
* `qgis_iface` returns stubbed [`QgsInterface`](https://qgis.org/pyqgis/master/gui/QgisInterface.html)
-* `new_project` makes sure that all the map layers and configurations are removed. This should be used with tests that
+* `qgis_new_project` makes sure that all the map layers and configurations are removed. This should be used with tests that
add stuff to [`QgsProject`](https://qgis.org/pyqgis/master/core/QgsProject.html).
* `qgis_processing` initializes the processing framework. This can be used when testing code that
calls `processing.run(...)`.
+* `qgis_version` returns QGIS version number as integer.
+* `qgis_world_map_geopackage` returns Path to the world_map.gpkg that ships with QGIS
+* `qgis_countries_layer` returns Natural Earth countries layer from world.map.gpkg as QgsVectorLayer
+
+### Markers
+
+* `qgis_show_map` lets developer inspect the QGIS map visually at the teardown of the test. **NOTE**: This marker is
+ still experimental and layer order might differ if using layers with different coordinate systems. Full signature of
+ the marker is:
+ ```python
+ @pytest.mark.qgis_show_map(timeout: int = 30, add_basemap: bool = False, zoom_to_common_extent: bool = True, extent: QgsRectangle = None)
+ ```
+ * `timeout` is the time in seconds until the map is closed.
+ * `add_basemap` when set to True, adds Natural Earth countries layer as the basemap for the map.
+ * `zoom_to_common_extent` when set to True, centers the map around all layers in the project.
+ * `extent` is alternative to `zoom_to_common_extent` and lets user specify the extent
+ as [`QgsRectangle`](https://qgis.org/pyqgis/master/core/QgsRectangle.html)
+
+Check the marker api [documentation](https://docs.pytest.org/en/latest/mark.html)
+and [examples](https://docs.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules) for the ways
+markers can be used.
+
+### Utility tools
+
+* `clean_qgis_layer` decorator found in `pytest_qgis.utils` is a decorator which can be used with `QgsMapLayer` fixtures
+ to ensure that they are cleaned properly if they are used but not added to the `QgsProject`. This is only needed with
+ layers with other than memory provider.
+ ```python
+ # conftest.py of start of a test file
+ import pytest
+ from pytest_qgis.utils import clean_qgis_layer
+ from qgis.core import QgsVectorLayer
+
+ @pytest.fixture()
+ @clean_qgis_layer
+ def some_layer() -> QgsVectorLayer:
+ return QgsVectorLayer("layer_file.geojson", "some layer")
+
+ ```
### Hooks
@@ -32,6 +72,19 @@ This plugin makes it easier to write QGIS plugin tests with the help of some fix
configure [`QgsApplication`](https://qgis.org/pyqgis/master/core/QgsApplication.html). With QGIS >= 3.18 it is also
used to patch `qgis.utils.iface` with `qgis_iface` automatically.
+### Command line options
+
+* `--qgis_disable_gui` can be used to disable graphical user interface in tests. This speeds up the tests that use Qt
+ widgets of the plugin.
+* `--qgis_disable_init` can be used to prevent QGIS (QgsApllication) from initializing. Mainly used in internal testing.
+
+### ini-options
+
+* `qgis_qui_enabled` whether the QUI will be visible or not. Defaults to `True`. Command line
+ option `--qgis_disable_gui` will override this.
+* `qgis_canvas_width` width of the QGIS canvas in pixels. Defaults to 600.
+* `qgis_canvas_height` height of the QGIS canvas in pixels. Defaults to 600.
+
## Requirements
This pytest plugin requires QGIS >= 3.10 to work.
diff --git a/requirements-dev.txt b/requirements-dev.txt
index a4e53cd..461fd89 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -8,10 +8,6 @@
# via -r requirements-dev.in
astor==0.8.1
# via flake8-qgis
-atomicwrites==1.4.0
- # via
- # -c requirements.txt
- # pytest
attrs==21.2.0
# via
# -c requirements.txt
@@ -25,6 +21,8 @@ bleach==3.3.1
# via readme-renderer
certifi==2021.5.30
# via requests
+cffi==1.15.0
+ # via cryptography
cfgv==3.3.0
# via pre-commit
charset-normalizer==2.0.4
@@ -32,14 +30,11 @@ charset-normalizer==2.0.4
click==8.0.3
# via black
colorama==0.4.4
- # via
- # -c requirements.txt
- # click
- # pytest
- # tqdm
- # twine
+ # via twine
coverage==5.5
# via pytest-cov
+cryptography==36.0.0
+ # via secretstorage
distlib==0.3.2
# via virtualenv
docutils==0.17.1
@@ -76,6 +71,10 @@ iniconfig==1.1.1
# pytest
isort==5.10.1
# via -r requirements-dev.in
+jeepney==0.7.1
+ # via
+ # keyring
+ # secretstorage
keyring==23.0.1
# via twine
mccabe==0.6.1
@@ -111,6 +110,8 @@ py==1.10.0
# pytest
pycodestyle==2.8.0
# via flake8
+pycparser==2.21
+ # via cffi
pyflakes==2.4.0
# via flake8
pygments==2.9.0
@@ -126,8 +127,6 @@ pytest==6.2.4
# pytest-qgis
pytest-cov==2.12.1
# via -r requirements-dev.in
-pywin32-ctypes==0.2.0
- # via keyring
pyyaml==5.4.1
# via pre-commit
readme-renderer==29.0
@@ -142,6 +141,8 @@ requests-toolbelt==0.9.1
# via twine
rfc3986==1.5.0
# via twine
+secretstorage==3.3.1
+ # via keyring
six==1.16.0
# via
# bleach
diff --git a/requirements.txt b/requirements.txt
index bbce64e..fcd0d1d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,12 +4,8 @@
#
# pip-compile
#
-atomicwrites==1.4.0
- # via pytest
attrs==21.2.0
# via pytest
-colorama==0.4.4
- # via pytest
iniconfig==1.1.1
# via pytest
packaging==21.0
diff --git a/setup.cfg b/setup.cfg
index d2f6aad..b991a84 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -61,6 +61,7 @@ doctest_encoding = utf-8
[flake8]
max_line_length = 88
per-file-ignores =
+ src/pytest_qgis/pytest_qgis.py:QGS105
src/pytest_qgis/qgis_interface.py:N802,N803
tests/*:ANN001,ANN201
extend-ignore =
diff --git a/src/pytest_qgis/pytest_qgis.py b/src/pytest_qgis/pytest_qgis.py
index a9ca3d8..ed8c07f 100644
--- a/src/pytest_qgis/pytest_qgis.py
+++ b/src/pytest_qgis/pytest_qgis.py
@@ -20,40 +20,136 @@
import os.path
+import shutil
import sys
+import time
+import warnings
+from collections import namedtuple
+from pathlib import Path
from typing import TYPE_CHECKING, Optional
from unittest import mock
import pytest
from _pytest.tmpdir import TempPathFactory
-from qgis.core import Qgis, QgsApplication
+from qgis.core import Qgis, QgsApplication, QgsProject, QgsRectangle, QgsVectorLayer
from qgis.gui import QgisInterface as QgisInterfaceOrig
from qgis.gui import QgsMapCanvas
from qgis.PyQt import QtCore, QtWidgets
-from qgis.PyQt.QtWidgets import QWidget
+from qgis.PyQt.QtCore import QCoreApplication
+from qgis.PyQt.QtWidgets import QMessageBox, QWidget
from pytest_qgis.mock_qgis_classes import MockMessageBar
from pytest_qgis.qgis_interface import QgisInterface
+from pytest_qgis.utils import (
+ clean_qgis_layer,
+ get_common_extent_from_all_layers,
+ get_layers_with_different_crs,
+ replace_layers_with_reprojected_clones,
+ set_map_crs_based_on_layers,
+)
if TYPE_CHECKING:
from _pytest.config import Config
+ from _pytest.config.argparsing import Parser
+ from _pytest.fixtures import SubRequest
+ from _pytest.mark import Mark
-try:
- QGIS_VERSION = Qgis.versionInt()
-except AttributeError:
- QGIS_VERSION = Qgis.QGIS_VERSION_INT
+Settings = namedtuple(
+ "Settings", ["gui_enabled", "qgis_init_disabled", "canvas_width", "canvas_height"]
+)
+ShowMapSettings = namedtuple(
+ "ShowMapSettings", ["timeout", "add_basemap", "zoom_to_common_extent", "extent"]
+)
+
+GUI_DISABLE_KEY = "qgis_disable_gui"
+GUI_ENABLED_KEY = "qgis_qui_enabled"
+GUI_DESCRIPTION = "Set whether the graphical user interface is wanted or not."
+GUI_ENABLED_DEFAULT = True
+
+CANVAS_HEIGHT_KEY = "qgis_canvas_height"
+CANVAS_WIDTH_KEY = "qgis_canvas_width"
+CANVAS_DESCRIPTION = "Set canvas height and width."
+CANVAS_SIZE_DEFAULT = (600, 600)
+
+DISABLE_QGIS_INIT_KEY = "qgis_disable_init"
+DISABLE_QGIS_INIT_DESCRIPTION = "Prevent QGIS (QgsApllication) from initializing."
+
+SHOW_MAP_MARKER = "qgis_show_map"
+SHOW_MAP_VISIBILITY_TIMEOUT_DEFAULT = 30
+SHOW_MAP_MARKER_DESCRIPTION = (
+ f"{SHOW_MAP_MARKER}(timeout={SHOW_MAP_VISIBILITY_TIMEOUT_DEFAULT}, add_basemap=False, zoom_to_common_extent=True, extent=None): " # noqa: E501
+ f"Show QGIS map for a short amount of time. The first keyword, *timeout*, is the "
+ f"timeout in seconds until the map closes. The second keyword *add_basemap*, "
+ f"when set to True, adds Natural Earth countries layer as the basemap for the map. "
+ f"The third keyword *zoom_to_common_extent*, when set to True, centers the map "
+ f"around all layers in the project. Alternatively the fourth keyword *extent* "
+ f"can be provided as QgsRectangle."
+)
_APP: Optional[QgsApplication] = None
_CANVAS: Optional[QgsMapCanvas] = None
_IFACE: Optional[QgisInterface] = None
_PARENT: Optional[QtWidgets.QWidget] = None
+_AUTOUSE_QGIS: Optional[bool] = None
+
+try:
+ _QGIS_VERSION = Qgis.versionInt()
+except AttributeError:
+ _QGIS_VERSION = Qgis.QGIS_VERSION_INT
+
+
+@pytest.hookimpl()
+def pytest_addoption(parser: "Parser") -> None:
+ group = parser.getgroup(
+ "qgis",
+ "Utilities for testing QGIS plugins",
+ )
+ group.addoption(f"--{GUI_DISABLE_KEY}", action="store_true", help=GUI_DESCRIPTION)
+ group.addoption(
+ f"--{DISABLE_QGIS_INIT_KEY}",
+ action="store_true",
+ help=DISABLE_QGIS_INIT_DESCRIPTION,
+ )
+
+ parser.addini(
+ GUI_ENABLED_KEY, GUI_DESCRIPTION, type="bool", default=GUI_ENABLED_DEFAULT
+ )
+
+ parser.addini(
+ CANVAS_WIDTH_KEY,
+ CANVAS_DESCRIPTION,
+ type="string",
+ default=CANVAS_SIZE_DEFAULT[0],
+ )
+ parser.addini(
+ CANVAS_HEIGHT_KEY,
+ CANVAS_DESCRIPTION,
+ type="string",
+ default=CANVAS_SIZE_DEFAULT[1],
+ )
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_configure(config: "Config") -> None:
+ """Configure and initialize qgis session for all tests."""
+ config.addinivalue_line("markers", SHOW_MAP_MARKER_DESCRIPTION)
+
+ settings = _parse_settings(config)
+ config._plugin_settings = settings
+
+ if not settings.gui_enabled:
+ os.environ["QT_QPA_PLATFORM"] = "offscreen"
+
+ _start_and_configure_qgis_app(config)
@pytest.fixture(autouse=True, scope="session")
-def qgis_app() -> QgsApplication:
- yield _APP
- assert _APP
- _APP.exitQgis()
+def qgis_app(request: "SubRequest") -> QgsApplication:
+ yield _APP if not request.config._plugin_settings.qgis_init_disabled else None
+
+ if not request.config._plugin_settings.qgis_init_disabled:
+ assert _APP
+ _APP.exitQgis()
@pytest.fixture(scope="session")
@@ -66,48 +162,133 @@ def qgis_canvas() -> QgsMapCanvas:
return _CANVAS
+@pytest.fixture(scope="session")
+def qgis_version() -> int:
+ """QGIS version number as integer."""
+ return _QGIS_VERSION
+
+
@pytest.fixture(scope="session")
def qgis_iface() -> QgisInterfaceOrig:
return _IFACE
+@pytest.fixture(scope="session")
+def qgis_processing(qgis_app: QgsApplication) -> None:
+ """
+ Initializes QGIS processing framework
+ """
+ _initialize_processing(qgis_app)
+
+
+@pytest.fixture()
+def qgis_new_project(qgis_iface: QgisInterface) -> None: # noqa QGS105
+ """
+ Initializes new QGIS project by removing layers and relations etc.
+ """
+ qgis_iface.newProject()
+
+
@pytest.fixture()
def new_project(qgis_iface: QgisInterface) -> None: # noqa QGS105
"""
Initializes new QGIS project by removing layers and relations etc.
+
+ Deprecated: use qgis_new_project instead.
"""
+ warnings.warn(
+ "new_project fixture will be deprecated. " "Use qgis_new_project instead.",
+ PendingDeprecationWarning,
+ )
qgis_iface.newProject()
-@pytest.fixture(scope="session")
-def qgis_processing(qgis_app: QgsApplication) -> None:
+@pytest.fixture()
+def qgis_world_map_geopackage(tmp_path: Path) -> Path:
"""
- Initializes QGIS processing framework
+ Path to natural world map geopackage containing Natural Earth data.
+ This geopackage can be modified in any way.
+
+ Layers:
+ * countries
+ * disputed_borders
+ * states_provinces
"""
- python_plugins_path = os.path.join(qgis_app.pkgDataPath(), "python", "plugins")
- if python_plugins_path not in sys.path:
- sys.path.append(python_plugins_path)
- from processing.core.Processing import Processing
+ world_map_gpkg = Path(
+ QgsApplication.pkgDataPath(), "resources", "data", "world_map.gpkg"
+ )
+ assert world_map_gpkg.exists(), world_map_gpkg
+ # Copy the geopackage to allow modifications
+ path_to_copied_geopackage = Path(shutil.copy(world_map_gpkg, tmp_path))
+ return path_to_copied_geopackage
- Processing.initialize()
+@pytest.fixture()
+@clean_qgis_layer
+def qgis_countries_layer(qgis_world_map_geopackage: Path) -> QgsVectorLayer:
+ """
+ Natural Earth countries as a QgsVectorLayer.
+ """
+
+ ne_countries = QgsVectorLayer(
+ f"{qgis_world_map_geopackage}|layername=countries",
+ "Natural Earth Countries",
+ "ogr",
+ )
+ assert ne_countries.isValid(), qgis_world_map_geopackage
+ return ne_countries
-def pytest_configure(config: "Config") -> None:
- """Initializes qgis session for all tests"""
+
+@pytest.fixture(autouse=True)
+def qgis_show_map(
+ qgis_app: QgsApplication,
+ qgis_iface: QgisInterface,
+ qgis_parent: QWidget,
+ qgis_countries_layer: QgsVectorLayer,
+ tmp_path: Path,
+ request: "SubRequest",
+) -> None:
+ """
+ Shows QGIS map if qgis_show_map marker is used.
+ """
+ show_map_marker = request.node.get_closest_marker(SHOW_MAP_MARKER)
+ common_settings: Settings = request.config._plugin_settings # type: ignore
+
+ if show_map_marker:
+ _show_qgis_dlg(common_settings, qgis_parent)
+
+ yield
+
+ if (
+ show_map_marker
+ and common_settings.gui_enabled
+ and not common_settings.qgis_init_disabled
+ ):
+ _configure_qgis_map(
+ qgis_app,
+ qgis_iface,
+ qgis_parent,
+ _parse_show_map_marker(show_map_marker),
+ tmp_path,
+ qgis_countries_layer,
+ )
+
+
+def _start_and_configure_qgis_app(config: "Config") -> None:
global _APP, _CANVAS, _IFACE, _PARENT
+ settings: Settings = config._plugin_settings # type: ignore
# Use temporary path for QGIS config
tmp_path_factory = TempPathFactory.from_config(config, _ispytest=True)
config_path = tmp_path_factory.mktemp("qgis-test")
os.environ["QGIS_CUSTOM_CONFIG_PATH"] = str(config_path)
- _APP = QgsApplication([], GUIenabled=False)
- _APP.initQgis()
-
- _PARENT = QtWidgets.QWidget()
+ if not settings.qgis_init_disabled:
+ _APP = QgsApplication([], GUIenabled=settings.gui_enabled)
+ _APP.initQgis()
+ _PARENT = QWidget()
_CANVAS = QgsMapCanvas(_PARENT)
-
- _CANVAS.resize(QtCore.QSize(400, 400))
+ _CANVAS.resize(QtCore.QSize(settings.canvas_width, settings.canvas_height))
# QgisInterface is a stub implementation of the QGIS plugin interface
_IFACE = QgisInterface(_CANVAS, MockMessageBar(), _PARENT)
@@ -116,7 +297,156 @@ def pytest_configure(config: "Config") -> None:
# This only works with QGIS >= 3.18 since before that
# importing qgis.utils causes RecursionErrors. See this issue for details
# https://github.com/qgis/QGIS/issues/40564
- if QGIS_VERSION >= 31800:
+ if _QGIS_VERSION >= 31800:
from qgis.utils import iface # noqa # This import is required
mock.patch("qgis.utils.iface", _IFACE).start()
+
+
+def _initialize_processing(qgis_app: QgsApplication) -> None:
+ python_plugins_path = os.path.join(qgis_app.pkgDataPath(), "python", "plugins")
+ if python_plugins_path not in sys.path:
+ sys.path.append(python_plugins_path)
+ from processing.core.Processing import Processing
+
+ Processing.initialize()
+
+
+def _show_qgis_dlg(common_settings: Settings, qgis_parent: QWidget) -> None:
+ if common_settings.gui_enabled and not common_settings.qgis_init_disabled:
+ qgis_parent.setWindowTitle("Test QGIS dialog opened by Pytest-qgis")
+ qgis_parent.show()
+ elif not common_settings.gui_enabled:
+ warnings.warn(
+ "Cannot show QGIS map because the GUI is not enabled. "
+ "Set qgis_qui_enabled=True in pytest.ini."
+ )
+ elif common_settings.qgis_init_disabled:
+ warnings.warn(
+ "Cannot show QGIS map because QGIS is not initialized. "
+ "Run the tests without --qgis_disable_init to enable QGIS map."
+ )
+
+
+def _configure_qgis_map(
+ qgis_app: QgsApplication,
+ qgis_iface: QgisInterface,
+ qgis_parent: QWidget,
+ settings: ShowMapSettings,
+ tmp_path: Path,
+ qgis_countries_layer: QgsVectorLayer,
+) -> None:
+ message_box = QMessageBox(qgis_parent)
+ try:
+ # Change project CRS to most common CRS if it is not set
+ if not QgsProject.instance().crs().isValid():
+ set_map_crs_based_on_layers()
+
+ extent = settings.extent
+ if settings.zoom_to_common_extent and extent is None:
+ extent = get_common_extent_from_all_layers()
+ if extent is not None:
+ qgis_iface.mapCanvas().setExtent(extent)
+
+ # Replace layers with different CRS
+ layers_with_different_crs = get_layers_with_different_crs()
+ if layers_with_different_crs:
+ _initialize_processing(qgis_app)
+ replace_layers_with_reprojected_clones(layers_with_different_crs, tmp_path)
+
+ if settings.add_basemap:
+ # Add Natural Earth Countries
+ QgsProject.instance().addMapLayer(qgis_countries_layer)
+ if qgis_countries_layer.crs() != QgsProject.instance().crs():
+ _initialize_processing(qgis_app)
+ replace_layers_with_reprojected_clones([qgis_countries_layer], tmp_path)
+
+ QgsProject.instance().reloadAllLayers()
+ qgis_iface.mapCanvas().refreshAllLayers()
+
+ message_box.setWindowTitle("pytest-qgis")
+ message_box.setText(
+ "Click close to close the map and to end the test.\n"
+ f"It will close automatically in {settings.timeout} seconds."
+ )
+ message_box.addButton(QMessageBox.Close)
+ message_box.move(
+ message_box.mapToGlobal(qgis_parent.rect().topLeft())
+ - QtCore.QPoint(message_box.width(), 0)
+ )
+ message_box.setWindowModality(QtCore.Qt.NonModal)
+ message_box.show()
+
+ t = time.time()
+ while time.time() - t < settings.timeout and message_box.isVisible():
+ QCoreApplication.processEvents()
+ finally:
+ message_box.close()
+ qgis_parent.close()
+
+
+def _parse_settings(config: "Config") -> Settings:
+ gui_disabled = config.getoption(GUI_DISABLE_KEY)
+ if not gui_disabled:
+ gui_enabled = config.getini(GUI_ENABLED_KEY)
+ else:
+ gui_enabled = not gui_disabled
+
+ qgis_init_disabled = config.getoption(DISABLE_QGIS_INIT_KEY)
+ canvas_width = int(config.getini(CANVAS_WIDTH_KEY))
+ canvas_height = int(config.getini(CANVAS_HEIGHT_KEY))
+
+ return Settings(gui_enabled, qgis_init_disabled, canvas_width, canvas_height)
+
+
+def _parse_show_map_marker(marker: "Mark") -> ShowMapSettings:
+ timeout = add_basemap = zoom_to_common_extent = extent = notset = object()
+
+ for kwarg, value in marker.kwargs.items():
+ if kwarg == "timeout":
+ timeout = value
+ elif kwarg == "add_basemap":
+ add_basemap = value
+ elif kwarg == "zoom_to_common_extent":
+ zoom_to_common_extent = value
+ elif kwarg == "extent":
+ extent = value
+ else:
+ raise TypeError(
+ f"Invalid keyword argument for qgis_show_map marker: {kwarg}"
+ )
+
+ if len(marker.args) >= 1 and timeout is not notset:
+ raise TypeError("Multiple values for timeout argument of qgis_show_map marker")
+ elif len(marker.args) >= 1:
+ timeout = marker.args[0]
+ if len(marker.args) >= 2 and add_basemap is not notset:
+ raise TypeError(
+ "Multiple values for add_basemap argument of qgis_show_map marker"
+ )
+ elif len(marker.args) >= 2:
+ add_basemap = marker.args[1]
+ if len(marker.args) >= 3 and zoom_to_common_extent is not notset:
+ raise TypeError(
+ "Multiple values for zoom_to_common_extent argument of qgis_show_map marker"
+ )
+ elif len(marker.args) >= 3:
+ zoom_to_common_extent = marker.args[2]
+ if len(marker.args) >= 4 and extent is not notset:
+ raise TypeError("Multiple values for extent argument of qgis_show_map marker")
+ elif len(marker.args) >= 4:
+ extent = marker.args[3]
+ if len(marker.args) > 4:
+ raise TypeError("Too many arguments for qgis_show_map marker")
+
+ if timeout is notset:
+ timeout = SHOW_MAP_VISIBILITY_TIMEOUT_DEFAULT
+ if add_basemap is notset:
+ add_basemap = False
+ if zoom_to_common_extent is notset:
+ zoom_to_common_extent = True
+ if extent is notset:
+ extent = None
+ elif not isinstance(extent, QgsRectangle):
+ raise TypeError("Extent has to be of type QgsRectangle")
+ return ShowMapSettings(timeout, add_basemap, zoom_to_common_extent, extent)
diff --git a/src/pytest_qgis/utils.py b/src/pytest_qgis/utils.py
new file mode 100644
index 0000000..cb3eac0
--- /dev/null
+++ b/src/pytest_qgis/utils.py
@@ -0,0 +1,198 @@
+# Copyright (C) 2021 pytest-qgis Contributors.
+#
+#
+# This file is part of pytest-qgis.
+#
+# pytest-qgis 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 2 of the License, or
+# (at your option) any later version.
+#
+# pytest-qgis 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 pytest-qgis. If not, see .
+#
+from collections import Counter
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable, List, Optional
+
+import sip
+from osgeo import gdal
+from qgis.core import (
+ QgsCoordinateReferenceSystem,
+ QgsCoordinateTransform,
+ QgsLayerTree,
+ QgsLayerTreeGroup,
+ QgsLayerTreeLayer,
+ QgsMapLayer,
+ QgsProject,
+ QgsRasterLayer,
+ QgsRectangle,
+ QgsVectorLayer,
+)
+
+DEFAULT_RASTER_FORMAT = "tif"
+
+DEFAULT_CRS = QgsCoordinateReferenceSystem("EPSG:4326")
+
+
+def get_common_extent_from_all_layers() -> Optional[QgsRectangle]:
+ """Get common extent from all QGIS layers in the project."""
+ map_crs = QgsProject.instance().crs()
+ layers = list(QgsProject.instance().mapLayers(validOnly=True).values())
+
+ if layers:
+ extent = transform_rectangle(layers[0].extent(), layers[0].crs(), map_crs)
+ for layer in layers[1:]:
+ extent.combineExtentWith(
+ transform_rectangle(layer.extent(), layer.crs(), map_crs)
+ )
+ return extent
+ return None
+
+
+def set_map_crs_based_on_layers() -> None:
+ """Set map crs based on layers of the project."""
+ crs_counter = Counter(
+ layer.crs().authid()
+ for layer in QgsProject.instance().mapLayers().values()
+ if layer.isSpatial()
+ )
+ if crs_counter:
+ crs_id, _ = crs_counter.most_common(1)[0]
+ crs = QgsCoordinateReferenceSystem(crs_id)
+ else:
+ crs = DEFAULT_CRS
+ QgsProject.instance().setCrs(crs)
+
+
+def transform_rectangle(
+ rectangle: QgsRectangle,
+ in_crs: QgsCoordinateReferenceSystem,
+ out_crs: QgsCoordinateReferenceSystem,
+) -> QgsRectangle:
+ """
+ Transform rectangle from one crs to other.
+ """
+ if in_crs == out_crs:
+ return rectangle
+
+ transform = QgsCoordinateTransform(
+ QgsCoordinateReferenceSystem(in_crs),
+ QgsCoordinateReferenceSystem(out_crs),
+ QgsProject.instance(),
+ )
+ box = transform.transformBoundingBox(rectangle)
+ return box
+
+
+def get_layers_with_different_crs() -> List[QgsMapLayer]:
+ map_crs = QgsProject.instance().crs()
+ return [
+ layer
+ for layer in QgsProject.instance().mapLayers().values()
+ if layer.crs() != map_crs
+ ]
+
+
+def replace_layers_with_reprojected_clones(
+ layers: List[QgsMapLayer], output_path: Path
+) -> None:
+ """
+ For some reason all layers having differing crs from the project are invisible.
+ Hotfix is to replace those by reprojected layers with map crs.
+ """
+ import processing
+
+ vector_layers = [
+ layer
+ for layer in layers
+ if isinstance(layer, QgsVectorLayer) and layer.isSpatial()
+ ]
+ raster_layers = [
+ layer
+ for layer in layers
+ if isinstance(layer, QgsRasterLayer) and layer.isSpatial()
+ ]
+
+ map_crs = QgsProject.instance().crs()
+ for input_layer in vector_layers:
+ output_layer: QgsVectorLayer = processing.run(
+ "native:reprojectlayer",
+ {"INPUT": input_layer, "TARGET_CRS": map_crs, "OUTPUT": "TEMPORARY_OUTPUT"},
+ )["OUTPUT"]
+ copy_layer_style_and_position(input_layer, output_layer, output_path)
+
+ for input_layer in raster_layers:
+ try:
+ output_raster = str(
+ Path(output_path, f"{input_layer.name()}.{DEFAULT_RASTER_FORMAT}")
+ )
+ warp = gdal.Warp(
+ output_raster, input_layer.source(), dstSRS=map_crs.authid()
+ )
+ finally:
+ warp = None # noqa F841
+
+ copy_layer_style_and_position(
+ input_layer, QgsRasterLayer(output_raster), output_path
+ )
+
+ # Remove originals from project
+ QgsProject.instance().removeMapLayers([layer.id() for layer in layers])
+
+
+def copy_layer_style_and_position(
+ layer1: QgsMapLayer, layer2: QgsMapLayer, tmp_path: Path
+) -> None:
+ """
+ Copy layer style and position to another layer.
+ """
+ style_file = str(Path(tmp_path, f"{layer1.id()}.qml"))
+ msg, succeeded = layer1.saveNamedStyle(style_file)
+ if succeeded:
+ layer2.loadNamedStyle(style_file)
+ layer2.setMetadata(layer1.metadata())
+ layer2.setName(layer1.name())
+ if layer2.isValid():
+ QgsProject.instance().addMapLayer(layer2, False)
+
+ root: QgsLayerTree = QgsProject.instance().layerTreeRoot()
+ layer_tree_layer: QgsLayerTreeLayer = root.findLayer(layer1)
+ group: QgsLayerTreeGroup = layer_tree_layer.parent()
+ index = {child.name(): i for i, child in enumerate(group.children())}[
+ layer_tree_layer.name()
+ ]
+
+ # TODO: it does not seem to matter how this is used.
+ # All new layers appear at he bottom...
+ group.insertLayer(index + 1, layer2)
+
+
+def clean_qgis_layer(fn: Callable) -> Callable:
+ """
+ Decorator to ensure that the QGIS layer (fixture) is cleaned properly.
+
+ Sometimes fixture non-memory layers that are used but not added
+ to the project might cause segmentation fault errors.
+ """
+
+ @wraps(fn)
+ def wrapper(*args: Any, **kwargs: Any) -> QgsMapLayer:
+ layer: QgsMapLayer = fn(*args, **kwargs)
+ yield layer
+
+ if (
+ isinstance(layer, QgsMapLayer)
+ and not sip.isdeleted(layer)
+ and layer.id() not in QgsProject.instance().mapLayers(True).keys()
+ ):
+ QgsProject.instance().addMapLayer(layer)
+ QgsProject.instance().removeMapLayer(layer)
+
+ return wrapper
diff --git a/tests/conftest.py b/tests/conftest.py
index 220c7c9..6501f35 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,6 +15,59 @@
#
# You should have received a copy of the GNU General Public License
# along with pytest-qgis. If not, see .
+import shutil
+from pathlib import Path
+import pytest
+from qgis.core import QgsRasterLayer, QgsVectorLayer
+
+from pytest_qgis.utils import clean_qgis_layer
pytest_plugins = "pytester"
+
+
+@pytest.fixture()
+def gpkg(tmp_path: Path) -> Path:
+ db = Path(Path(__file__).parent, "data", "db.gpkg")
+ new_db_path = tmp_path / "db.gpkg"
+ shutil.copy(db, new_db_path)
+ return new_db_path
+
+
+@pytest.fixture()
+@clean_qgis_layer
+def layer_polygon(gpkg: Path):
+ return get_gpkg_layer("polygon", gpkg)
+
+
+@pytest.fixture()
+@clean_qgis_layer
+def layer_polygon_3067(gpkg: Path):
+ return get_gpkg_layer("polygon_3067", gpkg)
+
+
+@pytest.fixture()
+@clean_qgis_layer
+def raster_3067():
+ return get_raster_layer(
+ "small raster 3067", Path(Path(__file__).parent, "data", "small_raster.tif")
+ )
+
+
+@pytest.fixture()
+@clean_qgis_layer
+def layer_points(gpkg: Path):
+ return get_gpkg_layer("points", gpkg)
+
+
+def get_gpkg_layer(name: str, gpkg: Path) -> QgsVectorLayer:
+ layer = QgsVectorLayer(f"{str(gpkg)}|layername={name}", name, "ogr")
+ layer.setProviderEncoding("utf-8")
+ assert layer.isValid()
+ return layer
+
+
+def get_raster_layer(name: str, path: Path) -> QgsRasterLayer:
+ layer = QgsRasterLayer(str(path), name)
+ assert layer.isValid()
+ return layer
diff --git a/tests/data/db.gpkg b/tests/data/db.gpkg
new file mode 100644
index 0000000..c813130
Binary files /dev/null and b/tests/data/db.gpkg differ
diff --git a/tests/data/small_raster.tif b/tests/data/small_raster.tif
new file mode 100644
index 0000000..b4f8f2b
Binary files /dev/null and b/tests/data/small_raster.tif differ
diff --git a/tests/test_pytest_qgis.py b/tests/test_pytest_qgis.py
index dc923d6..dc435ed 100644
--- a/tests/test_pytest_qgis.py
+++ b/tests/test_pytest_qgis.py
@@ -15,15 +15,16 @@
#
# You should have received a copy of the GNU General Public License
# along with pytest-qgis. If not, see .
+from typing import TYPE_CHECKING
import pytest
from qgis.core import Qgis, QgsProcessing, QgsProject, QgsVectorLayer
from qgis.utils import iface
-try:
- QGIS_VERSION = Qgis.versionInt()
-except AttributeError:
- QGIS_VERSION = Qgis.QGIS_VERSION_INT
+from .utils import QGIS_VERSION
+
+if TYPE_CHECKING:
+ from _pytest.pytester import Testdir
# DO not use this directly, this is only meant to be used with
# replace_iface_with_qgis_iface fixtrure
@@ -52,7 +53,7 @@ def test_add_layer():
assert set(QgsProject.instance().mapLayers().values()) == {layer}
-def test_new_project(new_project):
+def test_qgis_new_project(qgis_new_project):
assert QgsProject.instance().mapLayers() == {}
@@ -91,3 +92,49 @@ def test_processing_run(qgis_processing):
)
def test_setup_qgis_iface(qgis_iface):
assert iface == qgis_iface
+
+
+def test_ini_canvas(testdir: "Testdir"):
+ testdir.makeini(
+ """
+ [pytest]
+ qgis_canvas_height=1000
+ qgis_canvas_width=1200
+ """
+ )
+ testdir.makepyfile(
+ """
+ def test_canvas(qgis_canvas):
+ assert qgis_canvas.width() == 1200
+ assert qgis_canvas.height() == 1000
+ """
+ )
+ result = testdir.runpytest("--qgis_disable_init")
+ result.assert_outcomes(passed=1)
+
+
+@pytest.mark.parametrize("gui_enabled", [True, False])
+def test_ini_gui(gui_enabled: bool, testdir: "Testdir"):
+ testdir.makeini(
+ f"""
+ [pytest]
+ qgis_qui_enabled={gui_enabled}
+ """
+ )
+
+ testdir.makepyfile(
+ f"""
+ import os
+
+ def test_offscreen(qgis_new_project):
+ assert (os.environ.get("QT_QPA_PLATFORM", "") ==
+ "{'offscreen' if not gui_enabled else ''}")
+ """
+ )
+ result = testdir.runpytest("--qgis_disable_init")
+ result.assert_outcomes(passed=1)
+
+ result = testdir.runpytest("--qgis_disable_init", "--qgis_disable_gui")
+ result.assert_outcomes(
+ passed=1 if not gui_enabled else 0, failed=1 if gui_enabled else 0
+ )
diff --git a/tests/test_show_map.py b/tests/test_show_map.py
new file mode 100644
index 0000000..5fbfa16
--- /dev/null
+++ b/tests/test_show_map.py
@@ -0,0 +1,96 @@
+# Copyright (C) 2021 pytest-qgis Contributors.
+#
+#
+# This file is part of pytest-qgis.
+#
+# pytest-qgis 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 2 of the License, or
+# (at your option) any later version.
+#
+# pytest-qgis 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 pytest-qgis. If not, see .
+#
+import pytest
+from qgis.core import QgsProject, QgsRectangle
+
+from .utils import QGIS_VERSION
+
+"""
+These tests are meant to be tested visually by the developer.
+"""
+
+DEFAULT_TIMEOUT = 1
+
+
+@pytest.fixture(autouse=True)
+def setup(qgis_new_project):
+ pass
+
+
+@pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT)
+def test_show_map(layer_polygon):
+ QgsProject.instance().addMapLayers([layer_polygon])
+
+
+@pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT, extent=QgsRectangle(25, 65, 26, 66))
+def test_show_map_custom_extent(layer_polygon):
+ QgsProject.instance().addMapLayers([layer_polygon])
+
+
+@pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT, add_basemap=True)
+def test_show_map_with_basemap(layer_polygon):
+ QgsProject.instance().addMapLayers([layer_polygon])
+
+
+@pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT)
+@pytest.mark.skipif(
+ QGIS_VERSION < 31200, reason="QGIS 3.10 test image cannot find correct algorithms"
+)
+def test_show_map_crs_change_to_3067(
+ layer_polygon, layer_polygon_3067, raster_3067, qgis_version
+):
+ layer_polygon_3067.setOpacity(0.3)
+ if qgis_version > 31800:
+ raster_3067.setOpacity(0.9)
+ QgsProject.instance().addMapLayers([layer_polygon, layer_polygon_3067, raster_3067])
+
+
+@pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT, add_basemap=True)
+@pytest.mark.skipif(
+ QGIS_VERSION < 31200, reason="QGIS 3.10 test image cannot find correct algorithms"
+)
+def test_show_map_crs_change_to_3067_with_basemap(
+ layer_polygon, layer_polygon_3067, raster_3067, qgis_version
+):
+ layer_polygon_3067.setOpacity(0.3)
+ if qgis_version > 31800:
+ raster_3067.setOpacity(0.9)
+ QgsProject.instance().addMapLayers([layer_polygon, layer_polygon_3067, raster_3067])
+
+
+@pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT)
+@pytest.mark.skipif(
+ QGIS_VERSION < 31200, reason="QGIS 3.10 test image cannot find correct algorithms"
+)
+def test_show_map_crs_change_to_4326(
+ layer_polygon, raster_3067, layer_points, qgis_version
+):
+ if qgis_version > 31800:
+ raster_3067.setOpacity(0.9)
+ QgsProject.instance().addMapLayers([layer_points, raster_3067, layer_polygon])
+
+
+@pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT)
+@pytest.mark.skipif(
+ QGIS_VERSION < 31200, reason="QGIS 3.10 test image cannot find correct algorithms"
+)
+def test_show_map_crs_change_to_4326_2(layer_polygon, layer_points, layer_polygon_3067):
+ QgsProject.instance().addMapLayers(
+ [layer_points, layer_polygon_3067, layer_polygon]
+ )
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..dc7b76f
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,116 @@
+# Copyright (C) 2021 pytest-qgis Contributors.
+#
+#
+# This file is part of pytest-qgis.
+#
+# pytest-qgis 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 2 of the License, or
+# (at your option) any later version.
+#
+# pytest-qgis 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 pytest-qgis. If not, see .
+
+import pytest
+import sip
+from qgis.core import QgsCoordinateReferenceSystem, QgsProject, QgsVectorLayer
+
+from pytest_qgis.utils import (
+ clean_qgis_layer,
+ get_common_extent_from_all_layers,
+ get_layers_with_different_crs,
+ replace_layers_with_reprojected_clones,
+ set_map_crs_based_on_layers,
+)
+
+from .utils import QGIS_VERSION
+
+EPSG_4326 = "EPSG:4326"
+EPSG_3067 = "EPSG:3067"
+
+DEFAULT_CRS = QgsCoordinateReferenceSystem(EPSG_4326)
+
+
+@pytest.fixture()
+def crs():
+ QgsProject.instance().setCrs(DEFAULT_CRS)
+
+
+@pytest.fixture()
+def layers_added(qgis_new_project, layer_polygon, layer_polygon_3067, raster_3067):
+ QgsProject.instance().addMapLayers([raster_3067, layer_polygon_3067, layer_polygon])
+
+
+@pytest.mark.skipif(
+ QGIS_VERSION < 31200, reason="QGIS 3.10 test image cannot find correct algorithms"
+)
+def test_get_common_extent_from_all_layers(
+ qgis_new_project, crs, layer_polygon, layer_polygon_3067
+):
+ QgsProject.instance().addMapLayers([layer_polygon, layer_polygon_3067])
+ assert get_common_extent_from_all_layers().toString(0) == "23,61 : 32,68"
+
+
+@pytest.mark.skipif(
+ QGIS_VERSION < 31200, reason="QGIS 3.10 test image cannot find correct algorithms"
+)
+def test_set_map_crs_based_on_layers_should_set_4326(qgis_new_project, layer_polygon):
+ layer_polygon2 = layer_polygon.clone()
+ QgsProject.instance().addMapLayers([layer_polygon, layer_polygon2])
+ set_map_crs_based_on_layers()
+ assert QgsProject.instance().crs().authid() == EPSG_4326
+
+
+def test_set_map_crs_based_on_layers_should_set_3067(layers_added):
+ set_map_crs_based_on_layers()
+ assert QgsProject.instance().crs().authid() == EPSG_3067
+
+
+def test_get_layers_with_different_crs(
+ crs, layers_added, layer_polygon_3067, raster_3067
+):
+ assert set(get_layers_with_different_crs()) == {layer_polygon_3067, raster_3067}
+
+
+@pytest.mark.skipif(
+ QGIS_VERSION < 31200, reason="QGIS 3.10 test image cannot find correct algorithms"
+)
+def test_replace_layers_with_reprojected_clones(
+ crs, layers_added, qgis_processing, layer_polygon_3067, raster_3067, tmp_path
+):
+ vector_layer_id = layer_polygon_3067.id()
+ raster_layer_id = raster_3067.id()
+ vector_layer_name = layer_polygon_3067.name()
+ raster_layer_name = raster_3067.name()
+
+ replace_layers_with_reprojected_clones([layer_polygon_3067, raster_3067], tmp_path)
+
+ layers = {
+ layer.name(): layer for layer in QgsProject.instance().mapLayers().values()
+ }
+
+ assert {vector_layer_name, raster_layer_name}.issubset(set(layers.keys()))
+ assert layers[vector_layer_name].id() != vector_layer_id
+ assert layers[raster_layer_name].id() != raster_layer_id
+ assert layers[vector_layer_name].crs().authid() == EPSG_4326
+ assert layers[raster_layer_name].crs().authid() == EPSG_4326
+ assert (tmp_path / f"{vector_layer_id}.qml").exists()
+ assert (tmp_path / f"{raster_layer_id}.qml").exists()
+
+
+def test_clean_qgis_layer(layer_polygon):
+ layer = QgsVectorLayer(layer_polygon.source(), "another layer")
+
+ @clean_qgis_layer
+ def layer_function():
+ return layer
+
+ # Using list to trigger yield and the code that runs after it
+ list(layer_function())
+
+ assert sip.isdeleted(layer)
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..9b2f5ec
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,24 @@
+# Copyright (C) 2021 pytest-qgis Contributors.
+#
+#
+# This file is part of pytest-qgis.
+#
+# pytest-qgis 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 2 of the License, or
+# (at your option) any later version.
+#
+# pytest-qgis 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 pytest-qgis. If not, see .
+#
+from qgis.core import Qgis
+
+try:
+ QGIS_VERSION = Qgis.versionInt()
+except AttributeError:
+ QGIS_VERSION = Qgis.QGIS_VERSION_INT