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