From c043a95c616b236ba95ea6ec353f75e768eca911 Mon Sep 17 00:00:00 2001 From: David Marteau Date: Thu, 2 Apr 2026 10:56:22 +0200 Subject: [PATCH 1/3] Move to qgis_pytest test framework; refactor logging --- Makefile | 83 ++++-- lizmap/dialogs/dock_html_preview.py | 11 +- lizmap/dialogs/html_editor.py | 11 +- lizmap/dialogs/main.py | 31 ++- lizmap/dialogs/news.py | 16 +- lizmap/dialogs/server_wizard.py | 88 ++++--- lizmap/drag_drop_dataviz_manager.py | 14 +- lizmap/logger.py | 38 +-- lizmap/metadata.txt | 1 - lizmap/ogc_project_validity.py | 27 +- lizmap/plugin/core.py | 69 +++-- lizmap/plugin_manager.py | 30 +-- lizmap/server_dav.py | 52 ++-- lizmap/server_lwc.py | 41 ++- lizmap/table_manager/base.py | 43 ++-- lizmap/table_manager/dataviz.py | 22 +- lizmap/table_manager/layouts.py | 19 +- lizmap/toolbelt/custom_logging.py | 107 -------- lizmap/tooltip.py | 14 +- lizmap/version_checker.py | 21 +- lizmap/widgets/html_editor.py | 13 +- pyproject.toml | 21 +- requirements/dev.txt | 5 +- requirements/tests.txt | 2 + tests/conftest.py | 60 ++--- tests/pytest.ini | 7 +- tests/qgis_testing.py | 207 ++++++++------- tests/test_ui.py | 2 - uv.lock | 375 +++++++++++++++++++++++++++- 29 files changed, 833 insertions(+), 597 deletions(-) delete mode 100644 lizmap/toolbelt/custom_logging.py diff --git a/Makefile b/Makefile index 580fffcb..93977bc4 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,6 @@ SHELL:=bash PYTHON_MODULE=lizmap -QGIS_VERSION ?= 3.40 -include .localconfig.mk @@ -17,7 +16,7 @@ ifdef VIRTUAL_ENV # Always prefer active environment ACTIVE_VENV=--active endif -UV_RUN=uv run $(ACTIVE_VENV) +UV=uv run $(ACTIVE_VENV) endif @@ -32,13 +31,6 @@ REQUIREMENTS_GROUPS= \ REQUIREMENTS=$(patsubst %, requirements/%.txt, $(REQUIREMENTS_GROUPS)) -# Update only packaging dependencies -# Waiting for https://github.com/astral-sh/uv/issues/13705 -update-packaging-dependencies:: - uv lock -P qgis-plugin-package-ci -P qgis-plugin-transifex-ci - -update-packaging-dependencies:: update-requirements - update-requirements: $(REQUIREMENTS) # Require uv (https://docs.astral.sh/uv/) for extracting @@ -56,53 +48,92 @@ requirements/%.txt: uv.lock # Static analysis # -LINT_TARGETS=$(PYTHON_MODULE) tests $(EXTRA_LINT_TARGETS) +LINT_TARGETS=$(PYTHON_MODULE) $(EXTRA_LINT_TARGETS) lint: - @ $(UV_RUN) ruff check --output-format=concise $(LINT_TARGETS) + @ $(UV) ruff check --output-format=concise $(LINT_TARGETS) + @ $(UV) ruff check --output-format=concise --target-version=py310 tests lint-fix: - @ $(UV_RUN) ruff check --fix $(LINT_TARGETS) + @ $(UV) ruff check --fix $(LINT_TARGETS) + @ $(UV) ruff check --fix --target-version=py310 tests + +lint-preview: + @ $(UV) ruff check --preview --output-format=concise $(LINT_TARGETS) format: - @ $(UV_RUN) ruff format $(LINT_TARGETS) + @ $(UV) ruff format $(LINT_TARGETS) typecheck: - $(UV_RUN) mypy $(PYTHON_MODULE) + $(UV) mypy $(PYTHON_MODULE) + $(UV) mypy tests --python-version 3.10 scan: - @ $(UV_RUN) bandit -r $(PYTHON_MODULE) $(SCAN_OPTS) + @ $(UV) bandit -r $(PYTHON_MODULE) $(SCAN_OPTS) -check-uv-install: - @which uv > /dev/null || { \ - echo "You must install uv (https://docs.astral.sh/uv/)"; \ - exit 1; \ - } - # # Tests # test: - $(UV_RUN) pytest -v tests/ + $(UV) pytest -v tests/ # # Test using docker image # -QGIS_IMAGE_REPOSITORY ?= qgis/qgis + +ifdef REGISTRY_URL +REGISTRY_PREFIX=$(REGISTRY_URL)/ +else +REGISTRY_PREFIX=3liz +endif + +QGIS_VERSION ?= 3.44 +QGIS_IMAGE_REPOSITORY ?= ${REGISTRY_PREFIX}qgis-platform QGIS_IMAGE_TAG ?= $(QGIS_IMAGE_REPOSITORY):$(QGIS_VERSION) export QGIS_VERSION export QGIS_IMAGE_TAG export UID=$(shell id -u) export GID=$(shell id -g) + docker-test: - cd .docker && docker compose up \ + set -e; \ + cd .docker; + docker compose up \ --quiet-pull \ --abort-on-container-exit \ - --exit-code-from qgis - cd .docker && docker compose down -v + --exit-code-from qgis; \ + docker compose down -v; + +# +# Install/sync +# + +sync: + @echo "Synchronizing python's environment with frozen dependencies" + uv sync --all-groups --frozen --all-extras + +install-dev:: + @echo "Creating virtual python environment" + uv venv --system-site-packages --no-managed-python + +install-dev:: sync + +# +# Coverage +# + +# Run tests coverage +covtests: + @echo "Running coverage tests" + @ $(UV) coverage run -m pytest tests/ + +coverage: covtests + @echo "Building coverage html" + @ $(UV) coverage html + # # Code managment diff --git a/lizmap/dialogs/dock_html_preview.py b/lizmap/dialogs/dock_html_preview.py index 62a93ab1..2492ed41 100644 --- a/lizmap/dialogs/dock_html_preview.py +++ b/lizmap/dialogs/dock_html_preview.py @@ -1,9 +1,8 @@ +""" __copyright__ = 'Copyright 2023, 3Liz' __license__ = 'GPL version 3' __email__ = 'info@3liz.org' - -import logging - +""" from qgis.core import ( QgsApplication, QgsExpression, @@ -26,10 +25,8 @@ ) from qgis.utils import iface -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.resources import resources_path - -LOGGER = logging.getLogger('Lizmap') +from ..toolbelt.i18n import tr +from ..toolbelt.resources import resources_path # Detect available Web widget WEBKIT_AVAILABLE = False diff --git a/lizmap/dialogs/html_editor.py b/lizmap/dialogs/html_editor.py index f6da2a42..98238d93 100644 --- a/lizmap/dialogs/html_editor.py +++ b/lizmap/dialogs/html_editor.py @@ -1,16 +1,13 @@ +""" __copyright__ = 'Copyright 2023, 3Liz' __license__ = 'GPL version 3' __email__ = 'info@3liz.org' - -import logging - +""" from qgis.core import QgsVectorLayer from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout -from lizmap.toolbelt.i18n import tr -from lizmap.widgets.html_editor import HtmlEditorWidget - -LOGGER = logging.getLogger('Lizmap') +from ..toolbelt.i18n import tr +from ..widgets.html_editor import HtmlEditorWidget class HtmlEditorDialog(QDialog): diff --git a/lizmap/dialogs/main.py b/lizmap/dialogs/main.py index 0c62564c..6d2406c9 100644 --- a/lizmap/dialogs/main.py +++ b/lizmap/dialogs/main.py @@ -1,7 +1,3 @@ -from __future__ import annotations - -import logging - from pathlib import Path from typing import TYPE_CHECKING, Any @@ -64,15 +60,17 @@ simplify_provider_side, use_estimated_metadata, ) -from lizmap.qt_style_sheets import COMPLETE_STYLE_SHEET -from lizmap.saas import fix_ssl, is_lizmap_cloud -from lizmap.table_manager.upload_files import TableFilesManager -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.layer import relative_path -from lizmap.toolbelt.resources import load_ui, resources_path -from lizmap.toolbelt.strings import human_size -from lizmap.toolbelt.version import qgis_version_info -from lizmap.widgets.check_project import Checks, Headers, TableCheck + +from .. import logger +from ..qt_style_sheets import COMPLETE_STYLE_SHEET +from ..saas import fix_ssl, is_lizmap_cloud +from ..table_manager.upload_files import TableFilesManager +from ..toolbelt.i18n import tr +from ..toolbelt.layer import relative_path +from ..toolbelt.resources import load_ui, resources_path +from ..toolbelt.strings import human_size +from ..toolbelt.version import qgis_version_info +from ..widgets.check_project import Checks, Headers, TableCheck WEBKIT_AVAILABLE = False try: @@ -99,7 +97,6 @@ FORM_CLASS = load_ui('ui_lizmap.ui') -LOGGER = logging.getLogger("Lizmap") class LizmapDialog(QDialog, FORM_CLASS): @@ -734,7 +731,7 @@ def check_qgis_version(self, message_bar: bool = False, widget: bool = False) -> return False title = tr('QGIS server version is lower than QGIS desktop version') - LOGGER.error(title) + logger.error(title) description = tr('Your QGIS desktop is writing QGS project in the future compare to QGIS server.') @@ -1373,7 +1370,7 @@ def select_unknown_features_group(self): ")" ) layer.removeSelection() - LOGGER.debug("Expression used for checking groups not on the server :\n" + expression) + logger.debug("Expression used for checking groups not on the server :\n" + expression) layer.selectByExpression(expression) count = layer.selectedFeatureCount() self.display_message_bar( @@ -1429,5 +1426,5 @@ def activateWindow(self): """ When the dialog displayed, to trigger functions in the plugin when the dialog is opening. """ self.check_project_thumbnail() self.check_action_file_exists() - LOGGER.info("Opening the Lizmap dialog.") + logger.info("Opening the Lizmap dialog.") super().activateWindow() diff --git a/lizmap/dialogs/news.py b/lizmap/dialogs/news.py index 5379cd6c..81862509 100644 --- a/lizmap/dialogs/news.py +++ b/lizmap/dialogs/news.py @@ -1,21 +1,19 @@ +""" __copyright__ = 'Copyright 2024, 3Liz' __license__ = 'GPL version 3' __email__ = 'info@3liz.org' - -import logging - +""" from qgis.core import QgsSettings from qgis.PyQt.QtCore import Qt, QUrl from qgis.PyQt.QtGui import QDesktopServices, QPixmap from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox -from lizmap.definitions.definitions import LwcVersions -from lizmap.definitions.online_help import online_lwc_help -from lizmap.definitions.qgis_settings import Settings -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.resources import load_ui, resources_path +from ..definitions.definitions import LwcVersions +from ..definitions.online_help import online_lwc_help +from ..definitions.qgis_settings import Settings +from ..toolbelt.i18n import tr +from ..toolbelt.resources import load_ui, resources_path -LOGGER = logging.getLogger('Lizmap') FORM_CLASS = load_ui('ui_news.ui') diff --git a/lizmap/dialogs/server_wizard.py b/lizmap/dialogs/server_wizard.py index db1ff35c..c6f7aaa2 100644 --- a/lizmap/dialogs/server_wizard.py +++ b/lizmap/dialogs/server_wizard.py @@ -1,8 +1,5 @@ -from __future__ import annotations - import configparser import json -import logging import sys from base64 import b64encode @@ -46,21 +43,20 @@ ) from qgis.utils import OverrideCursor, iface -from lizmap.definitions.definitions import UNSTABLE_VERSION_PREFIX -from lizmap.definitions.online_help import online_lwc_help -from lizmap.definitions.qgis_settings import Settings -from lizmap.logger import log_function -from lizmap.saas import is_lizmap_cloud, webdav_properties -from lizmap.server_dav import WebDav -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.plugin import lizmap_user_folder, user_settings -from lizmap.toolbelt.version import version +from .. import logger +from ..definitions.definitions import UNSTABLE_VERSION_PREFIX +from ..definitions.online_help import online_lwc_help +from ..definitions.qgis_settings import Settings +from ..saas import is_lizmap_cloud, webdav_properties +from ..server_dav import WebDav +from ..toolbelt.i18n import tr +from ..toolbelt.plugin import lizmap_user_folder, user_settings +from ..toolbelt.version import version if TYPE_CHECKING: from qgis.PyQt.QtWidgets import QWidget -LOGGER = logging.getLogger('Lizmap') THUMBS = " 👍" DEBUG = True @@ -340,10 +336,10 @@ def __init__(self, parent: QWidget | None = None): layout.addWidget(label_warning) if QgsApplication.authManager().masterPasswordIsSet(): - LOGGER.debug("Master password is set : False") + logger.debug("Master password is set : False") label_warning.setVisible(False) else: - LOGGER.debug("Master password is set : True") + logger.debug("Master password is set : True") label_warning.setVisible(True) self.result_master_password = QLabel() @@ -351,17 +347,17 @@ def __init__(self, parent: QWidget | None = None): # noinspection PyArgumentList layout.addWidget(self.result_master_password) - @log_function + @logger.log_function def nextId(self) -> int: """ Next page, according to lizmap.com hosting. """ # Temporary disable the PG page # parent_wizard: ServerWizard = self.wizard() # if parent_wizard.is_lizmap_cloud: - # LOGGER.debug("After saving the auth ID, go the PostgreSQL page.") + # logger.debug("After saving the auth ID, go the PostgreSQL page.") # return WizardPages.AddOrNotPostgresqlPage # Finished - LOGGER.debug("After saving the auth ID, it's finished") + logger.debug("After saving the auth ID, it's finished") return -1 @@ -371,7 +367,7 @@ class AddOrNotPostgresqlPage(QWizardPage): def __init__(self, parent: QWidget | None = None): super().__init__(parent) - LOGGER.debug("Page : Add the PostgreSQL connection delivered with Lizmap") + logger.debug("Page : Add the PostgreSQL connection delivered with Lizmap") self.setTitle(tr("PostgreSQL")) layout = QVBoxLayout() @@ -401,12 +397,12 @@ def __init__(self, parent: QWidget | None = None): # def isComplete(self) -> bool: # """ Form validation before the next step. """ - # LOGGER.debug("Calling AddOrNotPGPage::isComplete") + # logger.debug("Calling AddOrNotPGPage::isComplete") # if self.field("postgresql_yes"): # # Add PG # # self.wizard().button(QWizard.NextButton).setVisible(True) # self.setFinalPage(False) - # LOGGER.debug("Enf of function isComplete, returning True to PG page") + # logger.debug("Enf of function isComplete, returning True to PG page") # self.completeChanged.emit() # return True # @@ -416,7 +412,7 @@ def __init__(self, parent: QWidget | None = None): # # No webdav module # self.setFinalPage(True) # # self.wizard().button(QWizard.NextButton).setVisible(False) - # LOGGER.debug("Enf of function isComplete, returning True, no dav") + # logger.debug("Enf of function isComplete, returning True, no dav") # self.completeChanged.emit() # return True # @@ -424,21 +420,21 @@ def __init__(self, parent: QWidget | None = None): # # Already has some repository, we do not suggest a new one # self.setFinalPage(True) # # self.wizard().button(QWizard.NextButton).setVisible(False) - # LOGGER.debug("Enf of function isComplete, returning True, already repositories") + # logger.debug("Enf of function isComplete, returning True, already repositories") # self.completeChanged.emit() # return True # # # Webdav repository # self.setFinalPage(False) # # self.wizard().button(QWizard.NextButton).setVisible(True) - # LOGGER.debug("Enf of function isComplete, returning True to add webdav directory") + # logger.debug("Enf of function isComplete, returning True to add webdav directory") # self.completeChanged.emit() # return True - @log_function + @logger.log_function def nextId(self) -> int: """ Next step. """ - LOGGER.debug("Calling AddOrNotPGPage::nextId") + logger.debug("Calling AddOrNotPGPage::nextId") if self.field("postgresql_yes"): return WizardPages.PostgresqlPage @@ -694,7 +690,7 @@ def create_remote_directory(self): dav_url = parent_wizard.dav_url auth_id = parent_wizard.auth_id - LOGGER.debug(f"Creating a folder called '{self.custom_name.text()}' on {dav_url}") + logger.debug(f"Creating a folder called '{self.custom_name.text()}' on {dav_url}") with OverrideCursor(Qt.CursorShape.WaitCursor): server_dav = WebDav(dav_url, auth_id) if parent_wizard._user: @@ -878,7 +874,7 @@ def __init__( self.setPage(WizardPages.CreateNewFolderDav, CreateNewFolderDavPage()) self.setPage(WizardPages.LizmapNewRepository, LizmapNewRepositoryPage()) - @log_function + @logger.log_function def validateCurrentPage(self): """Specific rules for page validation. """ if self.currentId() == WizardPages.LoginPasswordPage: @@ -919,9 +915,9 @@ def validateCurrentPage(self): return False if self.currentId() == WizardPages.MasterPasswordPage: - LOGGER.debug("Validate current page, going to save the auth") + logger.debug("Validate current page, going to save the auth") result = self.save_auth_id() - LOGGER.debug("Saving to the authentication database is : {} valid".format("" if result else "not")) + logger.debug("Saving to the authentication database is : {} valid".format("" if result else "not")) return result if self.currentId() == WizardPages.PostgresqlPage: @@ -951,14 +947,14 @@ def current_login(self) -> str: """ Cleaned input login. """ return self.clean_data(self.field("login")) - @log_function + @logger.log_function def save_auth_id(self) -> bool: """ Save login and password in the QGIS password manager. Only if it's a new server, it will be saved in the JSON file. """ if self.page(WizardPages.LoginPasswordPage).auth_widget.configId() != "": - LOGGER.info("The user is advanced, he used an existing auth, skip saving.") + logger.info("The user is advanced, he used an existing auth, skip saving.") self.auth_id = self.page(WizardPages.LoginPasswordPage).auth_widget.configId() self.save_json_server() return True @@ -978,7 +974,7 @@ def save_auth_id(self) -> bool: # noinspection PyArgumentList,PyUnresolvedReferences config.setConfig('realm', QUrl(url).host()) if self.auth_id: - LOGGER.debug(f"Edit current information authentication ID : {self.auth_id}") + logger.debug(f"Edit current information authentication ID : {self.auth_id}") # Edit config.setId(self.auth_id) result = auth_manager.storeAuthenticationConfig(config, True) @@ -986,27 +982,27 @@ def save_auth_id(self) -> bool: else: # Creation self.auth_id = auth_manager.uniqueConfigId() - LOGGER.debug(f"New authentication ID : {self.auth_id} is going to be created") + logger.debug(f"New authentication ID : {self.auth_id} is going to be created") config.setId(self.auth_id) result = auth_manager.storeAuthenticationConfig(config) - LOGGER.debug(f"New auth ID {self.auth_id} created") + logger.debug(f"New auth ID {self.auth_id} created") if result[0]: # Only for creation of the server, we save in the JSON self.save_json_server() if result[0]: - LOGGER.debug("Set thumbs") + logger.debug("Set thumbs") self.currentPage().result_master_password.setText(THUMBS) - LOGGER.info( + logger.info( f"Saving configuration with login/password ID {self.auth_id} = OK") return True - LOGGER.warning( + logger.warning( f"Saving configuration with login/password ID {self.auth_id} = NOK") self.currentPage().result_master_password.setText( tr("We couldn't save the login/password into the QGIS authentication database : NOK") ) - LOGGER.debug("Leaving function save_auth_id") + logger.debug("Leaving function save_auth_id") return False def save_json_server(self): @@ -1033,7 +1029,7 @@ def save_json_server(self): with open(user_settings(), 'w') as json_file: json_file.write(file_content) - LOGGER.debug("Server saved in the JSON file") + logger.debug("Server saved in the JSON file") @classmethod def override_url(cls, base_url: str, metadata: bool = True) -> str | None: @@ -1049,7 +1045,7 @@ def override_url(cls, base_url: str, metadata: bool = True) -> str | None: if base_url not in config.sections() and base_url[0:-1] not in config.sections(): return None - LOGGER.info(f"Found a server override for server {base_url}") + logger.info(f"Found a server override for server {base_url}") key = 'metadata' if metadata else 'dataviz' try: @@ -1168,7 +1164,7 @@ def _uri(self) -> QgsDataSourceUri: ) return uri - @log_function + @logger.log_function def test_pg(self) -> bool: """ Test the connection. """ uri = self._uri() @@ -1180,7 +1176,7 @@ def test_pg(self) -> bool: result = connection.executeSql("SELECT 1 AS lizmap_plugin_test") except QgsProviderConnectionException: # Credentials are wrong - LOGGER.warning("Wrong credentials about the PostgreSQL database") + logger.warning("Wrong credentials about the PostgreSQL database") else: if len(result) >= 1: self.currentPage().result_pg.setText(THUMBS) @@ -1192,7 +1188,7 @@ def test_pg(self) -> bool: self.currentPage().skip_db_label.setVisible(True) return False - @log_function + @logger.log_function def save_pg(self) -> bool: """ Save the current connection in the QGIS browser. """ name = self.field("pg_name") @@ -1208,10 +1204,10 @@ def save_pg(self) -> bool: return True @staticmethod - @log_function + @logger.log_function def _save_pg(name: str, uri: QgsDataSourceUri) -> bool: """ Save a PG connection from a URI. """ - LOGGER.info( + logger.info( f"Create PG connection '{name}' : host {uri.host()}, database {uri.database()}, user {uri.username()}, pass XXXXX, port {uri.port()}" ) config = { diff --git a/lizmap/drag_drop_dataviz_manager.py b/lizmap/drag_drop_dataviz_manager.py index e83eff34..a647d0cc 100644 --- a/lizmap/drag_drop_dataviz_manager.py +++ b/lizmap/drag_drop_dataviz_manager.py @@ -1,7 +1,3 @@ -from __future__ import annotations - -import logging - from enum import Enum, unique from typing import TYPE_CHECKING @@ -19,15 +15,13 @@ QTreeWidgetItemIterator, ) -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.resources import resources_path +from . import logger +from .toolbelt.i18n import tr +from .toolbelt.resources import resources_path if TYPE_CHECKING: from lizmap.definitions.dataviz import DatavizDefinitions -LOGGER = logging.getLogger('Lizmap') - - @unique class Container(Enum): Container = 'container' @@ -302,7 +296,7 @@ def _container_from_cfg(self, data: list, parent: str | None = None) -> bool: elif line['type'] == Container.Plot.value: text, icon = self.metadata_from_uuid(line["uuid"]) if not icon: - LOGGER.warning( + logger.warning( "Plot having UUID '{}' was not found in the plot combobox, D&D panel, skipping this plot for " "the drag&drop layout, only : {}.".format( line["uuid"], diff --git a/lizmap/logger.py b/lizmap/logger.py index e8f8f390..35876a7e 100644 --- a/lizmap/logger.py +++ b/lizmap/logger.py @@ -1,26 +1,35 @@ import functools -import logging import time -LOGGER = logging.getLogger('Lizmap') -DEBUG = True +from qgis.core import Qgis, QgsMessageLog -# Re-export +PLUGIN = "Lizmap" +PROFILE = False -debug = LOGGER.debug -info = LOGGER.info -warning = LOGGER.warning -error = LOGGER.error -critical = LOGGER.critical + +def info(message: str): + QgsMessageLog.logMessage(message, PLUGIN, Qgis.MessageLevel.Info) + + +def warning(message: str): + QgsMessageLog.logMessage(message, PLUGIN, Qgis.MessageLevel.Warning) + + +def critical(message: str): + QgsMessageLog.logMessage(message, PLUGIN, Qgis.MessageLevel.Critical) + + +debug = info +error = critical def log_function(func): """ Decorator to log function. """ @functools.wraps(func) def log_function_core(*args, **kwargs): - LOGGER.info(f"Calling function {func.__name__}") + info(f"Calling function {func.__name__}") value = func(*args, **kwargs) - LOGGER.info(f"End of function {func.__name__} with return : {value!s}") + info(f"End of function {func.__name__} with return : {value!s}") return value return log_function_core @@ -34,7 +43,7 @@ def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) end = time.time() - LOGGER.info(f"{func.__name__} ran in {round(end - start, 2)}s") + info(f"{func.__name__} ran in {round(end - start, 2)}s") return result return wrapper @@ -46,10 +55,7 @@ def log_output_value(func): @functools.wraps(func) def wrapper(*args, **kwargs): result = func(*args, **kwargs) - if DEBUG: - LOGGER.info(f"{func.__name__} output is {result} for parameter {args!s}") - else: - LOGGER.info(f"{func.__name__} output is {result[0:200]}… for parameter {args!s}") + debug(f"{func.__name__} output is {result} for parameter {args!s}") return result return wrapper diff --git a/lizmap/metadata.txt b/lizmap/metadata.txt index b7cc6231..d04677e5 100644 --- a/lizmap/metadata.txt +++ b/lizmap/metadata.txt @@ -1,7 +1,6 @@ [general] name=Lizmap qgisMinimumVersion=3.34 -qgisMaximumVersion=3.99 author=3Liz email=info@3liz.com description=Publish and share your QGIS maps on the Web via Lizmap Web Client, by 3liz.com diff --git a/lizmap/ogc_project_validity.py b/lizmap/ogc_project_validity.py index 64997a3a..13b0b07f 100644 --- a/lizmap/ogc_project_validity.py +++ b/lizmap/ogc_project_validity.py @@ -1,11 +1,9 @@ -from __future__ import annotations - +""" __copyright__ = 'Copyright 2023, 3Liz' __license__ = 'GPL version 3' __email__ = 'info@3liz.org' - +""" import collections -import logging import re from qgis.core import ( @@ -17,12 +15,11 @@ QgsProject, ) -from lizmap.definitions.definitions import LayerProperties -from lizmap.toolbelt.convert import cast_to_group, cast_to_layer -from lizmap.toolbelt.layer import layer_property, set_layer_property -from lizmap.toolbelt.strings import random_string, unaccent - -LOGGER = logging.getLogger('Lizmap') +from . import logger +from .definitions.definitions import LayerProperties +from .toolbelt.convert import cast_to_group, cast_to_layer +from .toolbelt.layer import layer_property, set_layer_property +from .toolbelt.strings import random_string, unaccent class OgcProjectValidity: @@ -39,7 +36,7 @@ def add_shortnames(self): existing, duplicated = self.existing_shortnames() layer_tree = self.project.layerTreeRoot() self._add_all_shortnames(layer_tree, existing, duplicated) - LOGGER.info(f"New shortnames added : {len(self.new_shortnames_added)}") + logger.info(f"New shortnames added : {len(self.new_shortnames_added)}") def _add_all_shortnames(self, layer_tree: QgsLayerTreeNode, existing_shortnames: list, duplicated: list): """ Recursive function to add shortnames. """ @@ -54,7 +51,7 @@ def _add_all_shortnames(self, layer_tree: QgsLayerTreeNode, existing_shortnames: new_shortname = self.short_name(source, existing_shortnames) existing_shortnames.append(new_shortname) set_layer_property(layer, LayerProperties.ShortName, new_shortname) - LOGGER.info(f"New shortname added on layer '{layer.name()}' : {new_shortname}") + logger.info(f"New shortname added on layer '{layer.name()}' : {new_shortname}") self.new_shortnames_added.append(new_shortname) else: child = cast_to_group(child) @@ -72,7 +69,7 @@ def _add_all_shortnames(self, layer_tree: QgsLayerTreeNode, existing_shortnames: else: child.serverProperties().setShortName(new_shortname) - LOGGER.info(f"New shortname added on group '{child.name()}' : {new_shortname}") + logger.info(f"New shortname added on group '{child.name()}' : {new_shortname}") self.new_shortnames_added.append(new_shortname) self._add_all_shortnames(child, existing_shortnames, duplicated) @@ -80,7 +77,7 @@ def existing_shortnames(self) -> tuple[list[str], list[str]]: """ Fetch all existing shortnames in the project. """ layer_tree = self.project.layerTreeRoot() existing = self._read_all_shortnames(layer_tree, []) - LOGGER.info('Existing shortnames detected before in project : ' + ', '.join(existing)) + logger.info('Existing shortnames detected before in project : ' + ', '.join(existing)) duplicated = [item for item, count in collections.Counter(existing).items() if count > 1] return existing, duplicated @@ -124,7 +121,7 @@ def set_project_short_name(self): root_layer_name = self.project.baseName() project_short_name = self.short_name(root_layer_name, existing, 'p') - LOGGER.info(f"Setting a project shortname : {project_short_name}") + logger.info(f"Setting a project shortname : {project_short_name}") self.project.writeEntry("WMSRootName", "/", project_short_name) @classmethod diff --git a/lizmap/plugin/core.py b/lizmap/plugin/core.py index e3a49eaa..63705d28 100644 --- a/lizmap/plugin/core.py +++ b/lizmap/plugin/core.py @@ -1,15 +1,15 @@ -from __future__ import annotations - import contextlib import json -import logging -import os -import tempfile from functools import partial from os.path import relpath from pathlib import Path -from typing import TYPE_CHECKING + +from typing import ( + TYPE_CHECKING, + Dict, + Optional, +) from qgis.core import ( Qgis, @@ -107,33 +107,29 @@ QGIS_PLUGIN_MANAGER = False -from lizmap.qt_style_sheets import NEW_FEATURE_CSS -from lizmap.server_lwc import MAX_DAYS, ServerManager -from lizmap.toolbelt.convert import ambiguous_to_bool, as_boolean -from lizmap.toolbelt.custom_logging import ( - add_logging_handler_once, - setup_logger, -) -from lizmap.toolbelt.git import current_git_hash, next_git_tag -from lizmap.toolbelt.i18n import setup_translation, tr -from lizmap.toolbelt.layer import ( +from ..qt_style_sheets import NEW_FEATURE_CSS +from ..server_lwc import MAX_DAYS, ServerManager +from ..toolbelt.convert import ambiguous_to_bool +from ..toolbelt.git import current_git_hash, next_git_tag +from ..toolbelt.i18n import setup_translation, tr +from ..toolbelt.layer import ( layer_property, ) -from lizmap.toolbelt.lizmap import convert_lizmap_popup -from lizmap.toolbelt.resources import ( +from ..toolbelt.lizmap import convert_lizmap_popup +from ..toolbelt.resources import ( load_icon, - plugin_name, plugin_path, window_icon, ) -from lizmap.toolbelt.version import ( +from ..toolbelt.version import ( qgis_version_info, version, ) -from lizmap.tooltip import Tooltip -from lizmap.version_checker import VersionChecker +from ..tooltip import Tooltip +from ..version_checker import VersionChecker + +from .. import logger -from . import helpers from .baselayers import BaseLayersManager from .config import ConfigFileManager from .dataviz import DatavizManager @@ -149,7 +145,8 @@ if TYPE_CHECKING: from qgis.gui import QgisInterface -LOGGER = logging.getLogger(plugin_name()) +from . import helpers + VERSION_URL = "https://raw.githubusercontent.com/3liz/lizmap-web-client/versions/versions.json" # To try a local file # VERSION_URL = 'file:///home/etienne/.local/share/QGIS/QGIS3/profiles/default/Lizmap/released_versions.json' @@ -178,7 +175,7 @@ def layerList(self) -> dict: def __init__(self, iface: QgisInterface, lwc_version: LwcVersions = None): """Constructor of the Lizmap plugin.""" - LOGGER.info("Plugin starting") + logger.info("Plugin starting") self.iface = iface # noinspection PyArgumentList self.project = QgsProject.instance() @@ -191,10 +188,8 @@ def __init__(self, iface: QgisInterface, lwc_version: LwcVersions = None): self.update_plugin = None - setup_logger(plugin_name()) - locale, file_path = setup_translation("lizmap_qgis_plugin_{}.qm", plugin_path("i18n")) - LOGGER.info(f"Language in QGIS : {locale}") + logger.info(f"Language in QGIS : {locale}") if file_path: self.translator = QTranslator() @@ -645,7 +640,7 @@ def target_repository_changed(self): def initGui(self): """Create action that will start plugin configuration""" - LOGGER.debug("Plugin starting in the initGui") + logger.debug("Plugin starting in the initGui") icon = window_icon() self.action = QAction(icon, "Lizmap", self.iface.mainWindow()) @@ -1095,7 +1090,7 @@ def set_initial_extent_from_project(self): self.dlg.widget_initial_extent.setOutputExtentFromUser( extent, self.iface.mapCanvas().mapSettings().destinationCrs() ) - LOGGER.info("Setting extent from the project") + logger.info("Setting extent from the project") def remove_selected_layer_from_table(self, key): """ @@ -1104,7 +1099,7 @@ def remove_selected_layer_from_table(self, key): """ tw = self.layers_table[key]["tableWidget"] tw.removeRow(tw.currentRow()) - LOGGER.info(f'Removing one row in table "{key}"') + logger.info(f'Removing one row in table "{key}"') def remove_layer_from_table_by_layer_ids(self, layer_ids: list): """ @@ -1138,7 +1133,7 @@ def remove_layer_from_table_by_layer_ids(self, layer_ids: list): if item_layer_id in layer_ids: tw.removeRow(row) - LOGGER.info(f'Layer ID "{layer_ids}" has been removed from the project') + logger.info(f'Layer ID "{layer_ids}" has been removed from the project') def layout_renamed(self, layout: QgsMasterLayoutInterface, new_name: str): """When a layout has been renamed in the project.""" @@ -1232,7 +1227,7 @@ def configure_html_popup(self): else: text = "" - LOGGER.info("Opening the popup configuration") + logger.info("Opening the popup configuration") layer = self._current_selected_layer() data = self.layer_options_list["popupSource"]["widget"].currentData() @@ -1240,7 +1235,7 @@ def configure_html_popup(self): # Legacy # Lizmap HTML popup if isinstance(layer, QgsVectorLayer): - LOGGER.warning( + logger.warning( "The 'lizmap' popup is deprecated for vector layer. This will be removed soon." ) @@ -1257,7 +1252,7 @@ def configure_html_popup(self): # Write the content into the global object self.layerList[layer_or_group]["popupTemplate"] = content if isinstance(layer, QgsVectorLayer): - LOGGER.warning( + logger.warning( "The 'lizmap' popup is deprecated for vector layer. This will be removed soon." ) @@ -1340,7 +1335,7 @@ def maptip_from_form(self): config = layer.editFormConfig() # noinspection PyUnresolvedReferences if config.layout() != QgsEditFormConfig.EditorLayout.TabLayout: - LOGGER.warning("Maptip : the layer is not using a drag and drop form.") + logger.warning("Maptip : the layer is not using a drag and drop form.") QMessageBox.warning( self.dlg, tr("Lizmap - Warning"), @@ -1387,7 +1382,7 @@ def write_project_config_file(self, lwc_version: LwcVersions, with_gui: bool = T with open(json_file, "w", encoding="utf8") as cfg_file: cfg_file.write(json_file_content) - LOGGER.info( + logger.info( 'The Lizmap configuration file has been written to "{path}"'.format( path=json_file.absolute(), ) diff --git a/lizmap/plugin_manager.py b/lizmap/plugin_manager.py index c050ba6a..aa591596 100644 --- a/lizmap/plugin_manager.py +++ b/lizmap/plugin_manager.py @@ -1,28 +1,24 @@ -from __future__ import annotations - +""" __copyright__ = 'Copyright 2022, 3Liz' __license__ = 'GPL version 3' __email__ = 'info@3liz.org' - -import logging - +""" from collections import namedtuple from pyplugin_installer import instance from qgis.PyQt.QtCore import QDate, QDateTime, QLocale, Qt from qgis.utils import iface -from lizmap.definitions.definitions import DEV_VERSION_PREFIX -from lizmap.server_lwc import ServerManager -from lizmap.toolbelt.plugin import plugin_date -from lizmap.toolbelt.version import version +from . import logger +from .definitions.definitions import DEV_VERSION_PREFIX +from .server_lwc import ServerManager +from .toolbelt.plugin import plugin_date +from .toolbelt.version import version Plugin = namedtuple('Plugin', ['name', 'version', 'date', 'template']) DAYS_BEFORE_OUTDATED = 60 -LOGGER = logging.getLogger('Lizmap') - class QgisPluginManager: @@ -80,26 +76,26 @@ def current_plugin_needs_update(self) -> bool | None: current_version = version() if current_version in DEV_VERSION_PREFIX: # We trust developers - LOGGER.debug("Version checker : in developers I trust") + logger.debug("Version checker : in developers I trust") return False current_version = ServerManager.split_lizmap_version(current_version) if 'Lizmap' not in self.metadata: # No QGIS plugin manager, nothing we can do now... - LOGGER.debug("Version checker : NO QPM, nothing we can do now...") + logger.debug("Version checker : NO QPM, nothing we can do now...") return False latest_version = self.metadata['Lizmap'].version if latest_version is None or latest_version == '': - LOGGER.debug("Version checker : NO QPM, nothing we can do now...") + logger.debug("Version checker : NO QPM, nothing we can do now...") return False latest_version = ServerManager.split_lizmap_version(latest_version) if current_version >= latest_version: # Need to check this one if the previous check # The current version is equal to the version in QGIS plugin manager - LOGGER.warning( + logger.warning( "Version checker : running a higher version than on plugins.qgis.org : " "current {current} >= latest {latest}".format( current='.'.join([str(i) for i in current_version]), @@ -115,13 +111,13 @@ def current_plugin_needs_update(self) -> bool | None: if not latest_date.isValid() or not current_plugin_date.isValid(): # We are missing some info, let's force them to update... - LOGGER.debug("Version checker : Missing some dates, they should upgrade") + logger.debug("Version checker : Missing some dates, they should upgrade") return True # We are nice, we let them quite a lot of days to update # Because we release a few versions per month must_update = latest_date.daysTo(current_plugin_date) > DAYS_BEFORE_OUTDATED - LOGGER.debug(f"Version checker : needs update : {must_update}") + logger.debug(f"Version checker : needs update : {must_update}") return must_update def lizmap_version(self): diff --git a/lizmap/server_dav.py b/lizmap/server_dav.py index 50064b80..f2f3420b 100644 --- a/lizmap/server_dav.py +++ b/lizmap/server_dav.py @@ -1,7 +1,3 @@ -from __future__ import annotations - -import logging - from base64 import b64encode from collections import namedtuple from pathlib import Path @@ -19,15 +15,15 @@ from qgis.PyQt.QtNetwork import QHttpMultiPart, QNetworkReply, QNetworkRequest from qgis.PyQt.QtXml import QDomDocument -from lizmap.definitions.definitions import RepositoryComboData, ServerComboData -from lizmap.saas import webdav_properties -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.strings import path_to_url +from . import logger +from .definitions.definitions import RepositoryComboData, ServerComboData +from .saas import webdav_properties +from .toolbelt.i18n import tr +from .toolbelt.strings import path_to_url if TYPE_CHECKING: - from lizmap.dialogs.main import LizmapDialog + from .dialogs.main import LizmapDialog -LOGGER = logging.getLogger("Lizmap") PropFindFileResponse = namedtuple( 'PropFindFile', @@ -166,11 +162,11 @@ def setup_webdav_dialog(self, dialog: LizmapDialog = None) -> bool: # return False if not self.dav_server: - LOGGER.debug("Webdav is not installed") + logger.debug("Webdav is not installed") return False # If we have the webdav URL, it means the user have 'lizmap.webdav.access' - LOGGER.debug(f"WebDAV is ready : {self.dav_server}") + logger.debug(f"WebDAV is ready : {self.dav_server}") return True def config_project(self): @@ -186,10 +182,10 @@ def send_all_project_files(self) -> tuple[bool, str, str]: if not url: return False, '', '' - LOGGER.info(f"SEND files to {url}") + logger.info(f"SEND files to {url}") loop = QEventLoop() - LOGGER.debug(f"Local path {self.qgs_path} to {url} with token {self.auth_id}") + logger.debug(f"Local path {self.qgs_path} to {url} with token {self.auth_id}") self.qgs = self.webdav.store(self.qgs_path, url, self.auth_id, Qgis.ActionStart.Deferred) self.qgs.stored.connect(loop.quit) self.qgs.store() @@ -197,11 +193,11 @@ def send_all_project_files(self) -> tuple[bool, str, str]: error = self.qgs.errorString() if error: - LOGGER.error("Error while sending the QGS file : " + error) + logger.error("Error while sending the QGS file : " + error) return False, error, '' loop = QEventLoop() - LOGGER.debug(f"Local path {self.cfg_path} to {url} with token {self.auth_id}") + logger.debug(f"Local path {self.cfg_path} to {url} with token {self.auth_id}") self.cfg = self.webdav.store(self.cfg_path, url, self.auth_id, Qgis.ActionStart.Deferred) self.cfg.stored.connect(loop.quit) self.cfg.store() @@ -209,11 +205,11 @@ def send_all_project_files(self) -> tuple[bool, str, str]: error = self.cfg.errorString() if error: - LOGGER.error("Error while sending the Lizmap configuration file : " + error) + logger.error("Error while sending the Lizmap configuration file : " + error) return False, error, '' url = self.project_url() - LOGGER.info(f"Project published on {url}") + logger.info(f"Project published on {url}") return True, '', url def send_thumbnail(self) -> tuple[bool, str]: @@ -230,7 +226,7 @@ def send_thumbnail(self) -> tuple[bool, str]: return False, '' loop = QEventLoop() - LOGGER.debug(f"Local path {self.thumbnail_path} to {url} with token {self.auth_id}") + logger.debug(f"Local path {self.thumbnail_path} to {url} with token {self.auth_id}") self.thumbnail = self.webdav.store(str(self.thumbnail_path), url, self.auth_id, Qgis.ActionStart.Deferred) self.thumbnail.stored.connect(loop.quit) self.thumbnail.store() @@ -238,7 +234,7 @@ def send_thumbnail(self) -> tuple[bool, str]: error = self.thumbnail.errorString() if error: - LOGGER.error("Error while sending the thumbnail : " + error) + logger.error("Error while sending the thumbnail : " + error) return False, error return True, self.thumbnail_url() @@ -257,7 +253,7 @@ def send_action(self) -> tuple[bool, str]: return False, '' loop = QEventLoop() - LOGGER.debug(f"Local path {self.action_path} to {url} with token {self.auth_id}") + logger.debug(f"Local path {self.action_path} to {url} with token {self.auth_id}") self.action = self.webdav.store(str(self.action_path), url, self.auth_id, Qgis.ActionStart.Deferred) self.action.stored.connect(loop.quit) self.action.store() @@ -265,7 +261,7 @@ def send_action(self) -> tuple[bool, str]: error = self.action.errorString() if error: - LOGGER.error("Error while sending the thumbnail : " + error) + logger.error("Error while sending the thumbnail : " + error) return False, error return True, '' @@ -282,7 +278,7 @@ def send_media(self, file_path: Path) -> tuple[bool, str]: url += 'media/' loop = QEventLoop() - LOGGER.debug(f"Local path {file_path} to {url} with token {self.auth_id}") + logger.debug(f"Local path {file_path} to {url} with token {self.auth_id}") self.media = self.webdav.store(str(file_path), url, self.auth_id, Qgis.ActionStart.Deferred) self.media.stored.connect(loop.quit) self.media.store() @@ -290,7 +286,7 @@ def send_media(self, file_path: Path) -> tuple[bool, str]: error = self.media.errorString() if error: - LOGGER.error("Error while sending the media : " + error) + logger.error("Error while sending the media : " + error) return False, error return True, '' @@ -386,7 +382,7 @@ def put_file(self, file_path: Path, remote_path: Path) -> tuple[bool, str]: remote_server = self.dav_server + directory + path_to_url(remote_path) loop = QEventLoop() - LOGGER.debug(f"Local path {file_path} to {remote_server} with token {self.auth_id}") + logger.debug(f"Local path {file_path} to {remote_server} with token {self.auth_id}") self.generic = self.webdav.store(str(file_path), remote_server, self.auth_id, Qgis.ActionStart.Deferred) self.generic.stored.connect(loop.quit) self.generic.store() @@ -394,7 +390,7 @@ def put_file(self, file_path: Path, remote_path: Path) -> tuple[bool, str]: error = self.generic.errorString() if error: - LOGGER.error("Error while sending the generic file : " + error) + logger.error("Error while sending the generic file : " + error) return False, error return True, '' @@ -487,7 +483,7 @@ def put_file(self, file_path: Path, remote_path: Path) -> tuple[bool, str]: # if reply.error() == QNetworkReply.ContentNotFoundError: # return False, 'The file does not exist on the server.' # - # LOGGER.error(reply.errorString()) + # logger.error(reply.errorString()) # return False, self.xml_reply_from_dav(reply) def check_exists_qgs(self) -> tuple[bool, str | None]: @@ -570,7 +566,7 @@ def file_stats(self, filename: str) -> tuple[PropFindFileResponse | None, str | # Return None and empty error message return None, '' - LOGGER.error(reply.errorString()) + logger.error(reply.errorString()) # Return None but try to parse the error message return None, self.xml_reply_from_dav(reply) diff --git a/lizmap/server_lwc.py b/lizmap/server_lwc.py index 8c0c27a3..8d38bb19 100644 --- a/lizmap/server_lwc.py +++ b/lizmap/server_lwc.py @@ -1,7 +1,4 @@ -from __future__ import annotations - import json -import logging import os import time @@ -55,18 +52,16 @@ NamePage, ServerWizard, ) -from lizmap.saas import is_lizmap_cloud, webdav_properties -from lizmap.toolbelt.convert import ambiguous_to_bool -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.plugin import lizmap_user_folder, user_settings -from lizmap.toolbelt.version import qgis_version_info, version - -if TYPE_CHECKING: - from pathlib import Path - from lizmap.dialogs.main import LizmapDialog +from . import logger +from .saas import is_lizmap_cloud, webdav_properties +from .toolbelt.convert import ambiguous_to_bool +from .toolbelt.i18n import tr +from .toolbelt.plugin import lizmap_user_folder, user_settings +from .toolbelt.version import qgis_version_info, version -LOGGER = logging.getLogger('Lizmap') +if TYPE_CHECKING: + from .dialogs.main import LizmapDialog class TableCell(Enum): @@ -237,16 +232,16 @@ def config_for_id(auth_id: str) -> QgsAuthMethodConfig | None: """ Fetch the authentication settings for a given token. """ auth_manager = QgsApplication.authManager() if not auth_manager.masterPasswordIsSet(): - LOGGER.warning(f"Master password is not set, could not look for ID {auth_id}") + logger.warning(f"Master password is not set, could not look for ID {auth_id}") return None conf = QgsAuthMethodConfig() auth_manager.loadAuthenticationConfig(auth_id, conf, True) if not conf.id(): - LOGGER.debug(f"Skipping password ID {auth_id}, it wasn't found in the password manager") + logger.debug(f"Skipping password ID {auth_id}, it wasn't found in the password manager") return None - # LOGGER.info("Found password ID {}".format(auth_id)) + # logger.info("Found password ID {}".format(auth_id)) return conf @classmethod @@ -414,7 +409,7 @@ def remove_row(self): QMessageBox.StandardButton.Ok) self.table.clearSelection() return - LOGGER.debug(f"Row {auth_id} removed from the QGIS authentication database") + logger.debug(f"Row {auth_id} removed from the QGIS authentication database") self.table.clearSelection() self.table.removeRow(row) @@ -645,7 +640,7 @@ def request_finished(self, row: int): # Add the JSON metadata in the server combobox index = self.server_combo.findData(url, ServerComboData.ServerUrl.value) self.server_combo.setItemData(index, content, ServerComboData.JsonMetadata.value) - LOGGER.info(f"Saving server metadata from network : {index} - {server_alias}") + logger.info(f"Saving server metadata from network : {index} - {server_alias}") self.parent.tooltip_server_combo(index) # Server combo is refreshed, maybe we can allow the menu bar self.check_dialog_validity() @@ -832,10 +827,10 @@ def refresh_server_combo(self): with open(cache_file, encoding='utf8') as f: metadata = json.load(f) self.server_combo.setItemData(index, metadata, ServerComboData.JsonMetadata.value) - LOGGER.info(f"Loading server {name} using cache in the drop down list") + logger.info(f"Loading server {name} using cache in the drop down list") else: self.server_combo.setItemData(index, {}, ServerComboData.JsonMetadata.value) - LOGGER.info( + logger.info( f"Loading server {name} without metadata in the drop down list") self.parent.tooltip_server_combo(index) @@ -1376,7 +1371,7 @@ def migrate_password_manager(self, servers: list): if f'@{url}' in conf.name(): # Old format - LOGGER.warning(f"Migrating the URL {url} in the QGIS authentication database") + logger.warning(f"Migrating the URL {url} in the QGIS authentication database") user = conf.config('username') password = conf.config('password') @@ -1391,7 +1386,7 @@ def migrate_password_manager(self, servers: list): result = auth_manager.storeAuthenticationConfig(config, True) if not result: - LOGGER.critical("Error while migrating the server") + logger.critical("Error while migrating the server") # Other entries in the authentication database for config in auth_manager.configIds(): @@ -1408,7 +1403,7 @@ def migrate_password_manager(self, servers: list): split = conf.name().split('@') if QUrl(split[-1]).isValid(): - LOGGER.critical( + logger.critical( f"Is the password ID '{config}' in the QGIS authentication database a Lizmap server URL ? If yes, " f"please remove it manually, otherwise skip this message. Go in the QGIS global properties, " f"then 'Authentication' panel and check this ID." diff --git a/lizmap/table_manager/base.py b/lizmap/table_manager/base.py index 21655ffc..e03ee94a 100644 --- a/lizmap/table_manager/base.py +++ b/lizmap/table_manager/base.py @@ -1,9 +1,7 @@ """Table manager.""" -from __future__ import annotations import inspect import json -import logging import os from collections import namedtuple @@ -21,18 +19,16 @@ QWidget, ) -from lizmap.definitions.base import BaseDefinitions, InputType, InputTypeError -from lizmap.definitions.dataviz import AggregationType, GraphType -from lizmap.definitions.definitions import LwcVersions -from lizmap.qt_style_sheets import NEW_FEATURE_CSS -from lizmap.toolbelt.convert import as_boolean -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.resources import plugin_name +from .. import logger +from ..definitions.base import BaseDefinitions, InputType +from ..definitions.dataviz import AggregationType, GraphType +from ..definitions.definitions import LwcVersions +from ..qt_style_sheets import NEW_FEATURE_CSS +from ..toolbelt.convert import as_boolean +from ..toolbelt.i18n import tr if TYPE_CHECKING: - from lizmap.dialogs.main import LizmapDialog - -LOGGER = logging.getLogger(plugin_name()) + from .dialogs.main import LizmapDialog class CellError(Exception): @@ -166,7 +162,7 @@ def __init__( def set_lwc_version(self, current_version: LwcVersions): """ When the target LWC version is changed, we need to update all widgets to set the color. """ - # LOGGER.debug("Set new LWC version {} in {}".format(current_version.value, self.definitions.key())) + # logger.debug("Set new LWC version {} in {}".format(current_version.value, self.definitions.key())) found = False for lwc_version in self.lwc_versions: if found: @@ -392,8 +388,9 @@ def _edit_row(self, row, data): break else: msg = f'Error with value = "{value}" in list "{key}"' - LOGGER.critical(msg) + logger.critical(msg) raise InputTypeError(msg) + cell.setText(text) if icon: cell.setIcon(QIcon(icon)) @@ -526,7 +523,7 @@ def layers_has_been_deleted(self, layer_ids): value = cell.data(Qt.ItemDataRole.UserRole) if value == layer_id: self.table.removeRow(i) - LOGGER.info(f"Removing '{layer_id}' from table {self.definitions.key()}") + logger.info(f"Removing '{layer_id}' from table {self.definitions.key()}") continue def truncate(self): @@ -705,7 +702,7 @@ def to_json(self, version: LwcVersions = None) -> dict: if layer_data.get('type') == 'numeric': if layer_data.get('end_field'): # Incompatible with this format, but we don't remove it just in case - LOGGER.error( + logger.error( "A end_field is defined for the form filter. " "This is not compatible for this version of Lizmap Web Client" ) @@ -766,7 +763,7 @@ def to_json(self, version: LwcVersions = None) -> dict: else: key = layer_name if result.get(layer_name): - LOGGER.warning( + logger.warning( f'Skipping "{layer_name}" while saving "{self.definitions.key()}" JSON configuration. Duplicated entry.') result[key] = layer result[key]['order'] = i @@ -947,7 +944,7 @@ def from_json(self, data: dict): if widget_type == InputType.Layer: vector_layer = self.project.mapLayer(value) if not vector_layer or not vector_layer.isValid(): - LOGGER.warning( + logger.warning( f'In Lizmap configuration file, section "{self.definitions.key()}" with key {config_key}, the layer with ID "{value}" is ' 'invalid or does not exist. Skipping that layer.') else: @@ -998,7 +995,7 @@ def from_json(self, data: dict): if not vector_layer or not vector_layer.isValid(): # A layer temporary not available will be found in the project, but "not valid". # Some metadata like CRS was still imported from the QGS file, but not fields - LOGGER.warning( + logger.warning( f'In Lizmap configuration file, section "{self.definitions.key()}", the layer with ID "{value}" is invalid or does ' 'not exist. Trying to keep configuration.') # Let's try to keep the configuration @@ -1033,7 +1030,7 @@ def from_json(self, data: dict): msg = ( f'Error with value = "{value}" in list "{key}", set default to {default_list_value}' ) - LOGGER.warning(msg) + logger.warning(msg) value = default_list_value layer_data[key] = value elif definition['type'] == InputType.SpinBox or definition['type'] == InputType.Text or definition['type'] == InputType.MultiLine or definition['type'] == InputType.HtmlWysiwyg or definition['type'] == InputType.Collection: @@ -1055,7 +1052,7 @@ def from_json(self, data: dict): layer_data[key] = default_value else: # raise InvalidCfgFile(') - LOGGER.warning( + logger.warning( f'In Lizmap configuration file, section "{self.definitions.key()}", one layer is missing the key "{key}" which is ' 'mandatory. Skipping that layer.') valid_layer = False @@ -1064,7 +1061,7 @@ def from_json(self, data: dict): if not valid_layer: # We didn't find any valid layer during the process of reading this JSON dictionary row = self.table.rowCount() - LOGGER.info( + logger.info( f"No valid layer found when reading this section {row + 1}. Not adding the row number {self.definitions.key()}" ) continue @@ -1077,7 +1074,7 @@ def from_json(self, data: dict): # In CI, we still want to test this layer, sorry. if vector_layer.dataProvider().name() != 'postgres': - LOGGER.warning( + logger.warning( f"The layer for editing {vector_layer.id()} is not stored in PostgreSQL. Now, only PostgreSQL layers " "are supported for editing capabilities. Removing this layer from the " "configuration.") diff --git a/lizmap/table_manager/dataviz.py b/lizmap/table_manager/dataviz.py index 4fb8301b..2a0bc6c4 100644 --- a/lizmap/table_manager/dataviz.py +++ b/lizmap/table_manager/dataviz.py @@ -1,8 +1,6 @@ """ Table manager for dataviz. """ -from __future__ import annotations import json -import logging from typing import TYPE_CHECKING @@ -27,20 +25,20 @@ from qgis.PyQt.QtWidgets import QAbstractButton, QDialog, QLabel, QWidget from qgis.utils import OverrideCursor -from lizmap.definitions.dataviz import GraphType -from lizmap.definitions.definitions import ServerComboData -from lizmap.dialogs.server_wizard import ServerWizard -from lizmap.table_manager.base import TableManager -from lizmap.toolbelt.convert import as_boolean -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.resources import plugin_name, resources_path -from lizmap.toolbelt.strings import merge_strings +from .. import logger +from ..definitions.dataviz import GraphType +from ..definitions.definitions import ServerComboData +from ..dialogs.server_wizard import ServerWizard +from ..table_manager.base import TableManager +from ..toolbelt.convert import as_boolean +from ..toolbelt.i18n import tr +from ..toolbelt.resources import resources_path +from ..toolbelt.strings import merge_strings if TYPE_CHECKING: from lizmap.definitions.base import BaseDefinitions from lizmap.dialogs.main import LizmapDialog -LOGGER = logging.getLogger(plugin_name()) class TableManagerDataviz(TableManager): @@ -295,7 +293,7 @@ def dataviz_expression_filter(self, layer_id: str) -> str | None: return None if len(relations) >= 2: - LOGGER.warning( + logger.warning( f"Many relations has been found for the dataviz preview with the layer ID '{layer_id}'. " "Only the first one is used." ) diff --git a/lizmap/table_manager/layouts.py b/lizmap/table_manager/layouts.py index 31b47915..2c8e9cf5 100644 --- a/lizmap/table_manager/layouts.py +++ b/lizmap/table_manager/layouts.py @@ -1,25 +1,18 @@ """ Table manager for layouts. """ -from __future__ import annotations - -import logging - from enum import Enum from typing import TYPE_CHECKING from qgis.core import QgsMasterLayoutInterface, QgsProject from qgis.PyQt.QtCore import Qt -from lizmap.definitions.definitions import LwcVersions -from lizmap.table_manager.base import TableManager -from lizmap.toolbelt.resources import plugin_name +from .. import logger +from ..definitions.definitions import LwcVersions +from .base import TableManager if TYPE_CHECKING: from qgis.PyQt.QtWidgets import QAbstractButton, QDialog, QWidget - from lizmap.definitions.base import BaseDefinitions -LOGGER = logging.getLogger(plugin_name()) - class TableManagerLayouts(TableManager): @@ -54,7 +47,7 @@ def label_dictionary_list() -> str: def load_qgis_layouts(self, data: dict): """ Load QGIS layouts into the table. """ - LOGGER.debug("Loading all layouts from the QGIS project :") + logger.debug("Loading all layouts from the QGIS project :") tmp_layout_cfg = {} if data: for layout in data.get(self.label_dictionary_list()): @@ -96,7 +89,7 @@ def load_qgis_layouts(self, data: dict): for layout_name in ordered_names: layout = qgis_layouts_by_name[layout_name] # TODO check for report ? - LOGGER.debug(f" * reading layout {layout.name()}") + logger.debug(f" * reading layout {layout.name()}") row = self.table.rowCount() self.table.setRowCount(row + 1) @@ -175,7 +168,7 @@ def layout_renamed(self, layout: QgsMasterLayoutInterface, new_name: str): value = cell.data(Qt.ItemDataRole.UserRole) if value == old_name: - LOGGER.info(f"Renaming layout from '{old_name}' to '{new_name}'") + logger.info(f"Renaming layout from '{old_name}' to '{new_name}'") cell.setData(Qt.ItemDataRole.UserRole, new_name) cell.setText(new_name) break diff --git a/lizmap/toolbelt/custom_logging.py b/lizmap/toolbelt/custom_logging.py deleted file mode 100644 index 32998896..00000000 --- a/lizmap/toolbelt/custom_logging.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Setting up logging using QGIS, file, Sentry...""" - -import logging - -from qgis.core import Qgis, QgsMessageLog - -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.resources import plugin_name - -PLUGIN_NAME = plugin_name() - - -def qgis_level(logging_level): - """Check for the corresponding QGIS Level according to Logging Level. - - For QGIS: - https://qgis.org/api/classQgis.html#a60c079f4d8b7c479498be3d42ec96257 - - For Logging: - https://docs.python.org/3/library/logging.html#levels - - :param logging_level: The Logging level - :type logging_level: basestring - - :return: The QGIS Level - :rtype: Qgis.MessageLevel - """ - if logging_level in ("CRITICAL", "ERROR"): - return Qgis.MessageLevel.Critical - - if logging_level == "WARNING": - return Qgis.MessageLevel.Warning - - return Qgis.MessageLevel.Info - - -class QgsLogHandler(logging.Handler): - """A logging handler that will log messages to the QGIS logging console.""" - - def __init__(self, level=logging.NOTSET): - logging.Handler.__init__(self) - - def emit(self, record): - """Try to log the message to QGIS if available, otherwise do nothing. - - :param record: logging record containing whatever info needs to be - logged. - """ - try: - QgsMessageLog.logMessage(record.getMessage(), PLUGIN_NAME, qgis_level(record.levelname)) - except MemoryError: - message = tr( - "Due to memory limitations on this machine, the plugin {} can not handle the full log" - ).format(PLUGIN_NAME) - print(message) - QgsMessageLog.logMessage(message, PLUGIN_NAME, Qgis.MessageLevel.Critical) - - -def add_logging_handler_once(logger, handler): - """A helper to add a handler to a logger, ensuring there are no duplicates. - - :param logger: Logger that should have a handler added. - :type logger: logging.logger - - :param handler: Handler instance to be added. It will not be added if an - instance of that Handler subclass already exists. - :type handler: logging.Handler - - :returns: True if the logging handler was added, otherwise False. - :rtype: bool - """ - class_name = handler.__class__.__name__ - for logger_handler in logger.handlers: - if logger_handler.__class__.__name__ == class_name: - return False - - logger.addHandler(handler) - return True - - -def setup_logger(logger_name): - """Run once when the module is loaded and enable logging. - - :param logger_name: The logger name that we want to set up. - :type logger_name: basestring - - Borrowed heavily from this: - http://docs.python.org/howto/logging-cookbook.html - - Now to log a message do:: - LOGGER.debug('Some debug message') - """ - logger = logging.getLogger(logger_name) - logger.setLevel(logging.DEBUG) - - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.DEBUG) - console_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - console_handler.setFormatter(console_formatter) - add_logging_handler_once(logger, console_handler) - - qgis_handler = QgsLogHandler() - qgis_formatter = logging.Formatter("%(levelname)s - %(message)s") - qgis_handler.setFormatter(qgis_formatter) - add_logging_handler_once(logger, qgis_handler) - - return logger diff --git a/lizmap/tooltip.py b/lizmap/tooltip.py index c2a312ba..9c2ec9dd 100644 --- a/lizmap/tooltip.py +++ b/lizmap/tooltip.py @@ -5,9 +5,6 @@ # Desktop lizmap/tooltip.py # Server lizmap_server/tooltip.py -from __future__ import annotations - -import logging import re from textwrap import dedent @@ -25,7 +22,8 @@ from qgis.gui import QgsExternalResourceWidget from qgis.PyQt.QtXml import QDomDocument -LOGGER = logging.getLogger('Lizmap') +from . import logger + SPACES = ' ' @@ -74,7 +72,7 @@ def create_popup_node_item_from_form( if isinstance(node, QgsAttributeEditorField): if node.idx() < 0: # The form might have been imported from QML with some not existing fields - LOGGER.warning( + logger.warning( f'Layer {layer.id()} does not have a valid editor field') return html @@ -103,7 +101,7 @@ def create_popup_node_item_from_form( if widget_type == 'ValueRelation': if not QgsProject.instance().mapLayer(widget_config['Layer']): # Issue #287 - LOGGER.warning( + logger.warning( f'Layer {layer.id()} does not have a valid value relation layer for field {fname}') return html @@ -115,7 +113,7 @@ def create_popup_node_item_from_form( if not referenced_layer: # Issue #287 - LOGGER.warning( + logger.warning( f'Layer {layer.id()} does not have a valid relation reference layer for field {fname}') return html @@ -139,7 +137,7 @@ def create_popup_node_item_from_form( node.label(), relation.id(), relation.referencingLayerId()) else: # Ticket https://github.com/3liz/qgis-lizmap-server-plugin/issues/82 - LOGGER.warning( + logger.warning( f"The node '{node.name()}::{node.label()}' cannot be processed for the tooltip " f"because the relation has not been found.") diff --git a/lizmap/version_checker.py b/lizmap/version_checker.py index af1ffe0f..5aab261c 100644 --- a/lizmap/version_checker.py +++ b/lizmap/version_checker.py @@ -1,25 +1,26 @@ +""" __copyright__ = 'Copyright 2020, 3Liz' __license__ = 'GPL version 3' __email__ = 'info@3liz.org' +""" import json -import logging from qgis.core import Qgis, QgsNetworkContentFetcher from qgis.PyQt.QtCore import QDate, QLocale, QUrl -from lizmap.definitions.definitions import ( +from . import logger +from .definitions.definitions import ( LwcVersions, ReleaseStatus, ServerComboData, ) -from lizmap.definitions.online_help import current_locale -from lizmap.dialogs.main import LizmapDialog -from lizmap.dialogs.news import NewVersionDialog -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.plugin import lizmap_user_folder +from .definitions.online_help import current_locale +from .dialogs.main import LizmapDialog +from .dialogs.news import NewVersionDialog +from .toolbelt.i18n import tr +from .toolbelt.plugin import lizmap_user_folder -LOGGER = logging.getLogger('Lizmap') DAYS_BEING_OUTDATED = 90 @@ -55,7 +56,7 @@ def request_finished(self): released_versions = json.loads(content) except json.JSONDecodeError: # Issue reported by rldhont by mail - LOGGER.error( + logger.error( "Error while reading the JSON file from Lizmap Web Client main repository, check the content with the " "QGIS debug panel" ) @@ -204,7 +205,7 @@ def check_outdated_version(self, lwc_version: LwcVersions, with_gui: True): self.dialog.display_message_bar(title, description, Qgis.MessageLevel.Warning, 10, details) return - LOGGER.warning( + logger.warning( f"This branch of Lizmap Web Client {lwc_version.value} is already outdated for more than {DAYS_BEING_OUTDATED} days. We encourage you " f"to upgrade to the latest {self.newest_release_branch} or {self.oldest_release_branche}. A possible update of the plugin in a few months will remove " "the support for writing the Lizmap configuration file to this version" diff --git a/lizmap/widgets/html_editor.py b/lizmap/widgets/html_editor.py index 42589742..03bcefa4 100644 --- a/lizmap/widgets/html_editor.py +++ b/lizmap/widgets/html_editor.py @@ -1,5 +1,4 @@ import json -import logging import os import re @@ -11,9 +10,10 @@ from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import QWidget -from lizmap.toolbelt.convert import as_boolean -from lizmap.toolbelt.i18n import tr -from lizmap.toolbelt.resources import load_ui, resources_path +from .. import logger +from ..toolbelt.convert import as_boolean +from ..toolbelt.i18n import tr +from ..toolbelt.resources import load_ui, resources_path WEBKIT_AVAILABLE = False try: @@ -43,7 +43,6 @@ FORM_CLASS = load_ui('ui_html_editor.ui') -LOGGER = logging.getLogger('Lizmap') # RegEx defined in QgsExpression.replaceExpressionText # This function replaces each expression between [% and %] in the string @@ -78,7 +77,7 @@ def __init__(self, parent): self.web_view = QWebView() else: self.web_view = QgsCodeEditorHTML() - LOGGER.warning( + logger.warning( "WebKit is not available, falling back on the QGIS native plain HTML editor. Please upgrade your " "set-up to have WebKit installed." ) @@ -150,7 +149,7 @@ def set_html_content(self, content: str): def _insert_qgis_expression(self, text: str): """ Insert text at the current cursor position. """ - LOGGER.debug(f"Adding expression '{text}' in the HTML") + logger.debug(f"Adding expression '{text}' in the HTML") self.insert_text(f'[% {text} %]') def insert_text(self, text: str): diff --git a/pyproject.toml b/pyproject.toml index 766940c3..f0b7c08b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ license-files = ["LICENSE"] readme = "README.md" classifiers = [ "Operating System :: OS Independent", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.11", @@ -28,11 +29,14 @@ repository ="https://github.com/3liz/lizmap-plugin" [dependency-groups] dev = [ + "coverage[toml]", {include-group = "tests"}, {include-group = "lint"}, ] tests = [ - "pytest >= 5.3", + "semver", + "pytest", + "pytest-qgis; python_full_version >= '3.10'", "webdavclient3", "psycopg[binary]", ] @@ -46,9 +50,13 @@ lint = [ packaging = [ "qt-transifex; python_full_version >= '3.12'", ] +tools = [ + "ipython", +] [tool.uv.sources] qt-transifex = { git = "https://github.com/3liz/qt-transifex" } +pytest-qgis = { git = "https://github.com/3liz/pytest-qgis.git" } [tool.yapt] plugin_source = "lizmap" @@ -81,7 +89,7 @@ indent-style = "space" [tool.ruff.lint] extend-select = [ "E", "F", "I", "ANN", "W", "T", "COM", "RUF", - "C4", "SIM", "TC", "RET", + "C4", "SIM", "TC", "RET", "PTH" ] # COM812 conflict with formatter ignore = [ @@ -93,7 +101,8 @@ ignore = [ "SIM108", # Use of ternary operator # TODO: Temporary ignore linter errors until # they are fixed - "T201", + "PTH", # Use pathlib + "T201", # print() "E501", # Line too long "RUF012", # Mutable class attributes should be annotated "RUF067", # `__init__` module should only contain docstrings and re-exports @@ -124,5 +133,9 @@ suppress-dummy-args = true python_version = "3.9" [[tool.mypy.overrides]] -module = "qgis.*" +module = [ + "qgis.*", + "osgeo.*", + "processing.*", +] ignore_missing_imports = true diff --git a/requirements/dev.txt b/requirements/dev.txt index 745d52ea..26d9d312 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -5,6 +5,7 @@ bandit==1.9.4 certifi==2026.5.20 charset-normalizer==3.4.7 colorama==0.4.6 ; sys_platform == 'win32' +coverage==7.14.1 detect-secrets==1.5.0 exceptiongroup==1.3.1 ; python_full_version < '3.11' idna==3.17 @@ -22,14 +23,16 @@ psycopg==3.3.4 psycopg-binary==3.3.4 ; implementation_name != 'pypy' pygments==2.20.0 pytest==9.0.3 +pytest-qgis @ git+https://github.com/3liz/pytest-qgis.git@364633963a2d7b75aabde9044b8b79c3cd40c7ab python-dateutil==2.9.0.post0 pyyaml==6.0.3 requests==2.34.2 rich==15.0.0 ruff==0.15.15 +semver==3.0.4 six==1.17.0 stevedore==5.8.0 -tomli==2.4.1 ; python_full_version < '3.11' +tomli==2.4.1 ; python_full_version <= '3.11' typing-extensions==4.15.0 tzdata==2026.2 ; sys_platform == 'win32' urllib3==2.7.0 diff --git a/requirements/tests.txt b/requirements/tests.txt index a243af6e..afd408b4 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -13,8 +13,10 @@ psycopg==3.3.4 psycopg-binary==3.3.4 ; implementation_name != 'pypy' pygments==2.20.0 pytest==9.0.3 +pytest-qgis @ git+https://github.com/3liz/pytest-qgis.git@364633963a2d7b75aabde9044b8b79c3cd40c7ab python-dateutil==2.9.0.post0 requests==2.34.2 +semver==3.0.4 six==1.17.0 tomli==2.4.1 ; python_full_version < '3.11' typing-extensions==4.15.0 ; python_full_version < '3.13' diff --git a/tests/conftest.py b/tests/conftest.py index ffd8b830..f903a8c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,28 +1,30 @@ -import logging import sys from pathlib import Path +from typing import Any import pytest -from qgis.core import Qgis, QgsApplication -from qgis.PyQt import Qt +from qgis.gui import QgisInterface +from qgis.PyQt.QtCore import QT_VERSION_STR -from .qgis_testing import start_app +from .qgis_testing import QGIS_VERSION_INT, install_logger_hook, load_plugin # with warnings.catch_warnings(): # warnings.filterwarnings("ignore", category=DeprecationWarning) # from osgeo import gdal +PLUGIN_SOURCE = "lizmap" + def pytest_report_header(config): from osgeo import gdal return ( - f"QGIS : {Qgis.versionInt()}\n" + f"QGIS : {QGIS_VERSION_INT}\n" f"Python GDAL : {gdal.VersionInfo('VERSION_NUM')}\n" f"Python : {sys.version}\n" - f"QT : {Qt.QT_VERSION_STR}" + f"QT : {QT_VERSION_STR}" ) @@ -31,6 +33,11 @@ def pytest_report_header(config): # +def pytest_sessionstart(session: pytest.Session): + """Start qgis application""" + install_logger_hook() + + @pytest.fixture(scope="session") def rootdir(request: pytest.FixtureRequest) -> Path: return request.config.rootpath @@ -41,40 +48,9 @@ def data(rootdir: Path) -> Path: return rootdir.joinpath("data") -# -# Session -# - - -# Path the 'qgis.utils.iface' property -# Which is not initialized when QGIS app -# is initialized from testing module - -def pytest_sessionstart(session): - """Start qgis application""" - sys.path.append("/usr/share/qgis/python") - start_app(session.path, False) - -# -# Logger hook -# - +@pytest.fixture(autouse=True, scope="session") +def plugin(rootdir: Path, qgis_iface: QgisInterface, qgis_processing: Any) -> Any: + plugin_path = rootdir.parent.joinpath(PLUGIN_SOURCE) + plugin = load_plugin(plugin_path, qgis_iface, processing=False) -def install_logger_hook(verbose: bool = False) -> None: - """Install message log hook""" - from qgis.core import Qgis - - # Add a hook to qgis message log - def writelogmessage(message, tag, level): - arg = f"{tag}: {message}" - if level == Qgis.MessageLevel.Warning: - logging.warning(arg) - elif level == Qgis.MessageLevel.Critical: - logging.error(arg) - elif verbose: - # Qgis is somehow very noisy - # log only if verbose is set - logging.info(arg) - - messageLog = QgsApplication.messageLog() - messageLog.messageReceived.connect(writelogmessage) + yield plugin diff --git a/tests/pytest.ini b/tests/pytest.ini index 30afb5bf..9b20f014 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -1,6 +1,11 @@ [pytest] log_cli=1 -log_cli_level=critical +log_cli_level=error +qgis_gui_enabled=false +# For some reason, callinf exitQgis() +# cause a segfault error when exiting +# test session +qgis_disable_exit=true norecursedirs= tests/data .local diff --git a/tests/qgis_testing.py b/tests/qgis_testing.py index 6290515e..733cbe8f 100644 --- a/tests/qgis_testing.py +++ b/tests/qgis_testing.py @@ -1,127 +1,124 @@ +import configparser +import importlib import logging -import os -import shutil import sys from pathlib import Path +from typing import ( + Any, + Optional, +) -from qgis.core import QgsApplication - - -# Path the 'qgis.utils.iface' property -# Which is not initialized when QGIS app -# is initialized from testing module -def _patch_iface(): - import qgis.utils - - from qgis.testing.mocked import get_iface - qgis.utils.iface = get_iface() - - -# NOTE: we cannot use qgis.testing.start_app() directly -# because it does not allow us to initialize the qgis settings -# path as we want. -def start_app(rootdir: Path, cleanup: bool = True): - from qgis.PyQt.QtCore import QCoreApplication, Qt - - sys.path.append("/usr/share/qgis/python/plugins/") # for processing - - display = os.getenv("DISPLAY") - if not display: - os.environ["QT_QPA_PLATFORM"] = "offscreen" - - global QGISAPP - - QCoreApplication.setOrganizationName(QgsApplication.QGIS_ORGANIZATION_NAME) - QCoreApplication.setOrganizationName(QgsApplication.QGIS_ORGANIZATION_DOMAIN) - QCoreApplication.setApplicationName(QgsApplication.QGIS_APPLICATION_NAME) - - QCoreApplication.setAttribute( - Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True - ) - - load_qgis_settings(rootdir) - - # See https://github.com/python/mypy/issues/5732 - QGISAPP = QgsApplication( # type: ignore [name-defined] - [], - True, - platformName="3liz-tests", - ) - - install_logger_hook(verbose=True) - - QGISAPP.initQgis() # type: ignore [name-defined] - print(QGISAPP.showSettings()) # type: ignore [name-defined] - - # Patch 'iface' in qgis.utils - _patch_iface() - - if cleanup: - import atexit - - @atexit.register - def exitQgis(): - QGISAPP.exitQgis() - - -def init_processing(): - sys.path.append("/usr/share/qgis/python/plugins/") - - from processing.core.Processing import Processing - Processing.initialize() - - -def load_qgis_settings(rootdir: Path): - from qgis.core import QgsSettings - from qgis.PyQt.QtCore import QSettings - - path = rootdir.joinpath(".qgis-settings") - - os.environ["QGIS_CUSTOM_CONFIG_PATH"] = str(path) - os.environ["QGIS_OPTIONS_PATH"] = str(path) - - settings_path = path.joinpath("profiles", "default") - - # Copy the ini file at correct location - settings_file = settings_path.joinpath( - QgsApplication.QGIS_ORGANIZATION_DOMAIN, - "QGIS3.ini", - ) - settings_file.parent.mkdir(parents=True, exist_ok=True) - - # Copy the ini file - settings = rootdir.joinpath("qgis_settings.ini") - if settings.exists(): - shutil.copyfile(settings, settings_file) - - QSettings.setDefaultFormat(QSettings.IniFormat) - QSettings.setPath(QSettings.IniFormat, QSettings.UserScope, str(settings_path)) - - qgssettings = QgsSettings() - print("Settings loaded from ", qgssettings.fileName()) +import semver +from qgis.core import Qgis, QgsApplication +from qgis.gui import QgisInterface +from qgis.server import QgsServerInterface # # Logger hook # +QGIS_VERSION_INT = Qgis.versionInt() -def install_logger_hook(verbose: bool = False) -> None: + +def install_logger_hook() -> None: """Install message log hook""" + logging.info("Installing logger hook") from qgis.core import Qgis # Add a hook to qgis message log - def writelogmessage(message, tag, level): + def writelogmessage(message, tag, level, *args): arg = f"{tag}: {message}" - if level == Qgis.MessageLevel.Warning: + if level == Qgis.Warning: logging.warning(arg) - elif level == Qgis.MessageLevel.Critical: + elif level == Qgis.Critical: logging.error(arg) - elif verbose: - # Qgis is somehow very noisy - # log only if verbose is set + else: logging.info(arg) messageLog = QgsApplication.messageLog() - messageLog.messageReceived.connect(writelogmessage) + if QGIS_VERSION_INT < 40000: + messageLog.messageReceived.connect(writelogmessage) + else: + messageLog.messageReceivedWithFormat.connect(writelogmessage) + +# +# Plugin loader +# + + +def load_plugin( + plugin_path: Path, + iface: Optional[QgisInterface] = None, + *, + processing: bool = False, +) -> Any: + package = _load_plugin(plugin_path, processing=processing) + init = package.classFactory(iface) + if processing: + init.initProcessing() + + return init + + +def load_server_plugin( + plugin_path: Path, + iface: QgsServerInterface, + *, + processing: bool = False, +) -> Any: + package = _load_plugin(plugin_path, processing=processing) + init = package.serverClassFactory(iface) + if processing: + init.initProcessing() + + return init + + +def _load_plugin( + plugin_path: Path, + *, + server: bool = False, + processing: bool = False, +) -> Any: + logging.info("Loading plugin: %s", plugin_path) + cp = configparser.ConfigParser() + with plugin_path.joinpath("metadata.txt").open() as f: + cp.read_file(f) + if server: + assert cp["server"].getboolean("server"), "Server plugin required" + if processing: + assert cp["general"].getboolean("hasProcessingProvider"), "Processing plugin required" + assert _check_qgis_version( + cp["general"].get("qgisMinimumVersion"), + cp["general"].get("qgisMaximumVersion"), + ), f"Qgis version {Qgis.version()} not supported" + + sys.path.append(str(plugin_path.parent)) + + plugin = plugin_path.name + + package = importlib.import_module(plugin) + assert plugin in sys.modules + + return package + + +def _check_qgis_version(minver: Optional[str], maxver: Optional[str]) -> bool: + version = semver.Version.parse(Qgis.QGIS_VERSION.split("-", maxsplit=1)[0]) + + def _version(ver: Optional[str]) -> semver.Version: + if not ver: + return version + + # Normalize version + parts = ver.split(".") + match len(parts): + case 1: + parts.extend(("0", "0")) + case 2: + parts.append("0") + return semver.Version.parse(".".join(parts)) + + return _version(minver) <= version <= _version(maxver) diff --git a/tests/test_ui.py b/tests/test_ui.py index b38e2eab..1251814b 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,7 +1,6 @@ """Test Lizmap dialog UI.""" import json -import logging from pathlib import Path @@ -91,7 +90,6 @@ def test_legend_options(self, data: Path): # NOTE: Seems that HTML widget not working in tests # See lizmap.widgets.html_editor line 117 - logging.warning("HTML widget not working in tests") # self.assertTrue('' in output['options']['datavizTemplate']) self.assertEqual( diff --git a/uv.lock b/uv.lock index 48061690..93a8a6e3 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,8 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.12' and python_full_version < '3.15'", - "python_full_version < '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", ] [[package]] @@ -215,6 +216,133 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + [[package]] name = "detect-secrets" version = "1.5.0" @@ -240,6 +368,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "future" version = "1.0.0" @@ -267,6 +404,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/c2/c0064cf15d026501a1ef70e42efd9c3f818663089399aacc5e37a82901c1/ipython-9.14.0.tar.gz", hash = "sha256:6f27ff0f1d9ea050e0551f71568bc4b34d8aba579e8f111c5b4175f44ac6b4aa", size = 4432601, upload-time = "2026-05-29T15:13:24.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a3/9e59340f02c1dc8f8c0a05b09244712b8609eb5439f9996e887e2b82f452/ipython-9.14.0-py3-none-any.whl", hash = "sha256:8fd984a3372c14b12790b084ba6b5cff5678c0cb063244a0034f06a51f20d6c2", size = 627457, upload-time = "2026-05-29T15:13:22.942Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -360,11 +574,14 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "bandit" }, + { name = "coverage", extra = ["toml"] }, { name = "detect-secrets" }, { name = "mypy" }, { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, + { name = "pytest-qgis" }, { name = "ruff" }, + { name = "semver" }, { name = "vulture" }, { name = "webdavclient3" }, ] @@ -381,19 +598,28 @@ packaging = [ tests = [ { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, + { name = "pytest-qgis" }, + { name = "semver" }, { name = "webdavclient3" }, ] +tools = [ + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] [package.metadata] [package.metadata.requires-dev] dev = [ { name = "bandit" }, + { name = "coverage", extras = ["toml"] }, { name = "detect-secrets" }, { name = "mypy" }, { name = "psycopg", extras = ["binary"] }, - { name = "pytest", specifier = ">=5.3" }, + { name = "pytest" }, + { name = "pytest-qgis", marker = "python_full_version >= '3.10'", git = "https://github.com/3liz/pytest-qgis.git" }, { name = "ruff" }, + { name = "semver" }, { name = "vulture" }, { name = "webdavclient3" }, ] @@ -407,9 +633,12 @@ lint = [ packaging = [{ name = "qt-transifex", marker = "python_full_version >= '3.12'", git = "https://github.com/3liz/qt-transifex" }] tests = [ { name = "psycopg", extras = ["binary"] }, - { name = "pytest", specifier = ">=5.3" }, + { name = "pytest" }, + { name = "pytest-qgis", marker = "python_full_version >= '3.10'", git = "https://github.com/3liz/pytest-qgis.git" }, + { name = "semver" }, { name = "webdavclient3" }, ] +tools = [{ name = "ipython" }] [[package]] name = "lxml" @@ -541,6 +770,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -639,6 +880,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/a9/a10a10f12e50993b5a3568a1a90fd70b85f83edc451875d312bf60cd39b8/parsimonious-0.11.0-py3-none-any.whl", hash = "sha256:32e3818abf9f05b3b9f3b6d87d128645e30177e91f614d2277d88a0aea98fae2", size = 54351, upload-time = "2025-11-12T01:33:46.652Z" }, ] +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -648,6 +898,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -657,6 +919,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "psycopg" version = "3.3.4" @@ -737,6 +1039,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -906,6 +1226,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-qgis" +version = "4.0.1.post1" +source = { git = "https://github.com/3liz/pytest-qgis.git#364633963a2d7b75aabde9044b8b79c3cd40c7ab" } +dependencies = [ + { name = "pytest" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1167,6 +1495,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1176,6 +1513,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + [[package]] name = "stevedore" version = "5.8.0" @@ -1248,6 +1599,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, ] +[[package]] +name = "traitlets" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, +] + [[package]] name = "transifex-python" version = "3.7.0" @@ -1311,6 +1671,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, ] +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + [[package]] name = "webdavclient3" version = "3.14.7" From 298a2a8131eebabb38a2a1b50164375e1a0ba872 Mon Sep 17 00:00:00 2001 From: David Marteau Date: Thu, 2 Apr 2026 13:49:03 +0200 Subject: [PATCH 2/3] Fix tests --- .docker/docker-compose.yml | 2 +- .docker/run-tests.sh | 2 + .github/workflows/tests.yml | 5 +- .gitlab-ci.yml | 46 +- Makefile | 4 +- lizmap/dialogs/server_wizard.py | 13 +- lizmap/plugin/config.py | 2 +- lizmap/plugin/core.py | 5 +- lizmap/plugin/layer_tree.py | 34 +- lizmap/plugin/project.py | 5 +- lizmap/toolbelt/debug.py | 12 - tests/test_layer_tree.py | 6 +- tests/test_ui.py | 1685 +++++++++++++++---------------- 13 files changed, 935 insertions(+), 886 deletions(-) delete mode 100644 lizmap/toolbelt/debug.py diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index 623479cb..f8ae164a 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -1,7 +1,7 @@ services: qgis: - user: ${UID}:${GID} image: ${QGIS_IMAGE_TAG} + user: ${UID}:${GID} network_mode: host container_name: qgis-lizmap-tests environment: diff --git a/.docker/run-tests.sh b/.docker/run-tests.sh index 067fcfb3..b8af5ed8 100644 --- a/.docker/run-tests.sh +++ b/.docker/run-tests.sh @@ -13,6 +13,8 @@ python3 -m venv $VENV --system-site-package echo "Installing requirements..." $VENV/bin/pip install -q --no-cache -r requirements/tests.txt +export PYTHONPATH="/usr/share/qgis/python/:$PYTHONPATH" + cd tests && $VENV/bin/python -m pytest -v diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd3be363..5a8c59aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,13 +16,13 @@ jobs: python-version: "3.12" architecture: x64 cache: "pip" - cache-dependency-path: "requirements/dev.txt" + cache-dependency-path: "requirements/lint.txt" - name: Install Python requirements run: pip install --quiet -r requirements/lint.txt - name: Run linter - run: ruff check --preview --output-format=concise lizmap + run: ruff check --output-format=concise lizmap - name: Run security check run: bandit -r lizmap -ll @@ -39,6 +39,7 @@ jobs: "3.34", "3.40", "3.44", + "4.0", ] steps: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7b048284..177fc49d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,16 +3,58 @@ variables: stages: - lint +- test - build - deploy +# ------------ +# Lint +# ------------ + lint: - image: $REGISTRY_URI/factory-ci-runner:qgis-ltr - stage: lint + image: ${REGISTRY_URL}/factory-ci-runner:qgis-${QGIS_FLAVOR} tags: - factory-plain + stage: lint script: + # No need since we do not type checking atm + #- pip install --quiet -r requirements/dev.txt - make lint + parallel: + matrix: + - QGIS_FLAVOR: [ "ltr", "release" ] + +# ------------ +# Tests +# ------------ + +.tests: + image: ${REGISTRY_URL}/factory-ci-runner:factory-ci + tags: + - factory-dind + stage: test + script: + - make docker-test QGIS_VERSION=$QGIS_FLAVOR + +qgis-tests: + extends: .tests + parallel: + matrix: + - QGIS_FLAVOR: [ + "3.34", + "3.40", + "3.44", + "4.0", + ] + + +# NOTE: Following jobs are triggered only on RELEASE_BRANCH == "true" + +# +# Packages +# + +>>>>>>> 6c46c59 (Fix tests) build: image: $REGISTRY_URI/factory-ci-runner:qgis-plugin-ci diff --git a/Makefile b/Makefile index 93977bc4..5b5f59b7 100644 --- a/Makefile +++ b/Makefile @@ -86,7 +86,7 @@ test: ifdef REGISTRY_URL REGISTRY_PREFIX=$(REGISTRY_URL)/ else -REGISTRY_PREFIX=3liz +REGISTRY_PREFIX=3liz/ endif QGIS_VERSION ?= 3.44 @@ -100,7 +100,7 @@ export GID=$(shell id -g) docker-test: set -e; \ - cd .docker; + cd .docker; \ docker compose up \ --quiet-pull \ --abort-on-container-exit \ diff --git a/lizmap/dialogs/server_wizard.py b/lizmap/dialogs/server_wizard.py index c6f7aaa2..ae077cf5 100644 --- a/lizmap/dialogs/server_wizard.py +++ b/lizmap/dialogs/server_wizard.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from qgis.core import ( + Qgis, QgsAbstractDatabaseProviderConnection, QgsApplication, QgsAuthMethodConfig, @@ -874,6 +875,11 @@ def __init__( self.setPage(WizardPages.CreateNewFolderDav, CreateNewFolderDavPage()) self.setPage(WizardPages.LizmapNewRepository, LizmapNewRepositoryPage()) + # TODO: If these methods raises an exception while called + # by the Qt framework this cause a "Fatal Python error" + # These method should be bounded by try/except when used from + # Qt framework. + @logger.log_function def validateCurrentPage(self): """Specific rules for page validation. """ @@ -1090,7 +1096,12 @@ def request_check_url(self, url: str, login: str, password: str) -> tuple[bool, net_req.setUrl(QUrl(url)) token = b64encode(f"{login}:{password}".encode()) net_req.setRawHeader(b"Authorization", b"Basic %s" % token) - net_req.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) + # NOTE: According to QT6 doc this is enabled by default + # See https://doc.qt.io/archives/qt-5.15/qnetworkrequest.html#RedirectPolicy-enum + # See https://doc.qt.io/qt-6/network-changes-qt6.html#redirect-policies + if Qgis.versionInt() < 40000: + net_req.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) + request = QgsBlockingNetworkRequest() error = request.get(net_req) if error == QgsBlockingNetworkRequest.ErrorCode.NetworkError: diff --git a/lizmap/plugin/config.py b/lizmap/plugin/config.py index 59d7f8c0..d4668fa8 100644 --- a/lizmap/plugin/config.py +++ b/lizmap/plugin/config.py @@ -322,7 +322,7 @@ def save_cfg_file( self.project.layerTreeRoot(), GroupNames.BaseLayers, ) - if qgis_group and self.lwc_version >= LwcVersions.Lizmap_3_7: + if qgis_group is not None and self.lwc_version >= LwcVersions.Lizmap_3_7: self.disable_legacy_empty_base_layer() if self.version_checker: diff --git a/lizmap/plugin/core.py b/lizmap/plugin/core.py index 63705d28..4bc40798 100644 --- a/lizmap/plugin/core.py +++ b/lizmap/plugin/core.py @@ -107,6 +107,7 @@ QGIS_PLUGIN_MANAGER = False +from .. import logger from ..qt_style_sheets import NEW_FEATURE_CSS from ..server_lwc import MAX_DAYS, ServerManager from ..toolbelt.convert import ambiguous_to_bool @@ -127,9 +128,7 @@ ) from ..tooltip import Tooltip from ..version_checker import VersionChecker - -from .. import logger - +from . import helpers from .baselayers import BaseLayersManager from .config import ConfigFileManager from .dataviz import DatavizManager diff --git a/lizmap/plugin/layer_tree.py b/lizmap/plugin/layer_tree.py index fa0d30cb..fa7b17d0 100644 --- a/lizmap/plugin/layer_tree.py +++ b/lizmap/plugin/layer_tree.py @@ -1,6 +1,4 @@ """Layer tree panel configuration""" -from __future__ import annotations - import contextlib import hashlib import json @@ -10,6 +8,7 @@ TYPE_CHECKING, Any, Protocol, + Tuple, ) from qgis.core import ( @@ -883,7 +882,7 @@ def _add_group_legend( root_group = project.layerTreeRoot() qgis_group = self.existing_group(root_group, label) - if qgis_group: + if qgis_group is not None: return qgis_group new_group = root_group.addGroup(label) @@ -892,16 +891,16 @@ def _add_group_legend( return new_group @staticmethod - def existing_group( - root_group: QgsLayerTree, + def _existing_group( + root_group: Optional[QgsLayerTree], label: str, index: bool = False, - ) -> QgsLayerTreeGroup | int | None: + ) -> Optional[Tuple[QgsLayerTreeGroup, int]]: """Return the existing group in the legend if existing. It will either return the group itself if found, or its index. """ - if not root_group: + if root_group is None: return None # Iterate over all child (layers and groups) @@ -912,8 +911,7 @@ def existing_group( i += 1 continue - qgis_group = cast_to_group(child) - qgis_group: QgsLayerTreeGroup + qgis_group: QgsLayerTreeGroup = cast_to_group(child) count_children = len(qgis_group.children()) if count_children >= 1 or qgis_group.name() == label: # We do not want to count empty groups @@ -921,10 +919,26 @@ def existing_group( i += 1 if qgis_group.name() == label: - return i if index else qgis_group + return (qgis_group, i) return None + @staticmethod + def existing_group( + root_group: Optional[QgsLayerTree], + label: str, + ) -> Optional[QgsLayerTreeGroup]: + g = LayerTreeManager._existing_group(root_group, label) + return g[0] if g is not None else None + + @staticmethod + def existing_group_index( + root_group: Optional[QgsLayerTree], + label: str, + ) -> Optional[QgsLayerTreeGroup]: + g = LayerTreeManager._existing_group(root_group, label) + return g[1] if g is not None else None + def _add_base_layer( self, source: str, diff --git a/lizmap/plugin/project.py b/lizmap/plugin/project.py index bd7209cd..7ee7a47b 100644 --- a/lizmap/plugin/project.py +++ b/lizmap/plugin/project.py @@ -536,14 +536,13 @@ def project_config_file( GroupNames.BaseLayers, ) - if base_layers_group: + if base_layers_group is not None: base_layers_group: QgsLayerTreeGroup base_layers_group.setIsMutuallyExclusive(True, -1) - default_background_color_index = LayerTreeManager.existing_group( + default_background_color_index = LayerTreeManager.existing_group_index( base_layers_group, GroupNames.BackgroundColor, - index=True, ) if default_background_color_index is not None and default_background_color_index >= 0: diff --git a/lizmap/toolbelt/debug.py b/lizmap/toolbelt/debug.py deleted file mode 100644 index c349ada0..00000000 --- a/lizmap/toolbelt/debug.py +++ /dev/null @@ -1,12 +0,0 @@ -from qgis.PyQt.QtCore import Qt -from qgis.PyQt.QtWidgets import QComboBox - - -def _debug_combobox(combo: QComboBox, data_start: int = Qt.ItemDataRole.UserRole, data_max: int = 0): - """Debug a QComboBox.""" - for i in range(combo.count()): - print("=== NEW ITEM ===") - print(combo.itemText(i)) - for x in range(data_max): - print(f"→ {data_start + x} : {combo.itemData(data_start + x)}") - print("==== END ITEM ====") diff --git a/tests/test_layer_tree.py b/tests/test_layer_tree.py index 5d87b05d..924b1262 100644 --- a/tests/test_layer_tree.py +++ b/tests/test_layer_tree.py @@ -3,7 +3,7 @@ from pathlib import Path from qgis.core import Qgis, QgsProject, QgsVectorLayer -from qgis.testing.mocked import get_iface +from qgis.gui import QgisInterface from lizmap.definitions.definitions import LayerProperties, LwcVersions from lizmap.plugin import Lizmap @@ -57,7 +57,7 @@ def test_string_to_list(self): self.assertListEqual(string_to_list(" a "), ["a"]) self.assertListEqual(string_to_list("a,b"), ["a", "b"]) - def test_layer_metadata(self, data: Path): + def test_layer_metadata(self, data: Path, qgis_iface: QgisInterface): """Test metadata coming from layer or from Lizmap.""" project = QgsProject.instance() layer_name = "lines" @@ -68,7 +68,7 @@ def test_layer_metadata(self, data: Path): lizmap_config_url = "https://lizmap.url" qgis_config_url = "https://qgis.url" - lizmap = Lizmap(get_iface(), lwc_version=LwcVersions.latest()) + lizmap = Lizmap(qgis_iface, lwc_version=LwcVersions.latest()) # New project so Lizmap is empty config = lizmap.layers_config_file() diff --git a/tests/test_ui.py b/tests/test_ui.py index 1251814b..afa7805e 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -7,8 +7,8 @@ import pytest from qgis.core import QgsProject, QgsRasterLayer, QgsVectorLayer +from qgis.gui import QgisInterface from qgis.PyQt.QtCore import Qt -from qgis.testing.mocked import get_iface from lizmap.definitions.definitions import LwcVersions, PredefinedGroup from lizmap.plugin import Lizmap @@ -25,848 +25,841 @@ def teardown(data: Path) -> None: filepath.unlink() -class TestUiLizmapDialog(TestCase): - def test_ui_base(self, data: Path): - """Test opening the Lizmap dialog with some basic checks.""" - project = QgsProject.instance() - project.clear() - lizmap = Lizmap(get_iface(), lwc_version=LwcVersions.latest()) - - layer = QgsVectorLayer(str(data.joinpath("lines.geojson")), "lines", "ogr") - project.addMapLayer(layer) - - layer = QgsVectorLayer(str(data.joinpath("points.geojson")), "points", "ogr") - project.addMapLayer(layer) - - flag, message = lizmap.check_global_project_options() - self.assertFalse(flag, message) - self.assertEqual( - message, - "You need to open a QGIS project, using the QGS extension.
This is needed before using other tabs in " - "the plugin.", - ) - - project.write(str(data.joinpath("unittest.qgs"))) - flag, message = lizmap.check_global_project_options() - self.assertTrue(flag, message) - - # lizmap.run() - # lizmap.get_map_options() - - def test_legend_options(self, data: Path): - """Test about reading legend options.""" - project = QgsProject.instance() - project.read(str(data.joinpath("legend_image_option.qgs"))) - self.assertEqual(3, len(project.mapLayers())) - - lizmap = Lizmap(get_iface(), lwc_version=LwcVersions.latest()) - # read_cfg_file will call "layers_config_file" - config = lizmap.read_cfg_file(skip_tables=True) - print("\n::test_legend_options::config", config) - - lizmap.process_node(lizmap.layerList, project.layerTreeRoot(), None, config) - - self.assertEqual("5000", lizmap.dlg.minimum_scale.text()) - self.assertEqual("500000", lizmap.dlg.maximum_scale.text()) - self.assertEqual("5000, 250000, 500000", lizmap.dlg.list_map_scales.text()) - - self.assertEqual("disabled", lizmap.layerList.get("legend_disabled_layer_id").get("legend_image_option")) - - self.assertEqual( - "expand_at_startup", - lizmap.layerList.get("legend_displayed_startup_layer_id").get("legend_image_option"), - ) - - self.assertEqual( - "hide_at_startup", lizmap.layerList.get("legend_hidden_startup_layer_id").get("legend_image_option") - ) - - # For LWC 3.6 - output = lizmap.project_config_file( - LwcVersions.Lizmap_3_6, - check_server=False, - ignore_error=True, - ) - - # NOTE: Seems that HTML widget not working in tests - # See lizmap.widgets.html_editor line 117 - # self.assertTrue('
' in output['options']['datavizTemplate']) - - self.assertEqual( - output["layers"]["legend_displayed_startup"]["legend_image_option"], "expand_at_startup" - ) - self.assertIsNone(output["layers"]["legend_displayed_startup"].get("noLegendImage")) - - self.assertIsNone(output["options"].get("default_background_color_index")) - - # For LWC 3.5 - output = lizmap.project_config_file( - LwcVersions.Lizmap_3_5, with_gui=False, check_server=False, ignore_error=True - ) - self.assertIsNone(output["layers"]["legend_displayed_startup"].get("legend_image_option")) - self.assertEqual(output["layers"]["legend_displayed_startup"]["noLegendImage"], str(False)) - - def _setup_empty_project( - self, - data: Path, - lwc_version: LwcVersions = LwcVersions.latest(), - ) -> Lizmap: - """Internal function to add a layer and a basic check.""" - project = QgsProject.instance() - layer = QgsVectorLayer(str(data.joinpath("lines.geojson")), "lines", "ogr") - project.addMapLayer(layer) - project.setFileName(temporary_file_path()) - - lizmap = Lizmap(get_iface(), lwc_version=lwc_version) - baselayers = lizmap._add_group_legend("baselayers", exclusive=True, parent=None, project=project) - lizmap._add_group_legend( - "project-background-color", exclusive=False, parent=baselayers, project=project - ) - hidden = lizmap._add_group_legend("hidden", project=project) - - # For testing, we add OSM as hidden layer - hidden_raster = QgsRasterLayer( - "type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OSM", "wms" - ) - project.addMapLayer(hidden_raster, False) - hidden.addLayer(hidden_raster) - - # Do not use read_lizmap_config_file - # as it will be called by read_cfg_file and also the UI is set in read_cfg_file - config = lizmap.read_cfg_file(skip_tables=True) - - lizmap.dlg.widget_initial_extent.setOutputExtentFromLayer(layer) - - # Config is empty in the CFG file because it's a new project - self.assertDictEqual({}, config) - - # Some process - lizmap.process_node(lizmap.layerList, project.layerTreeRoot(), None, {}) - - return lizmap - - def test_lizmap_layer_properties(self, data: Path): - """Test apply some properties in a layer in the dialog.""" - lizmap = self._setup_empty_project(data) - - # Click the layer - item = lizmap.dlg.layer_tree.topLevelItem(0) - self.assertEqual(item.text(0), "lines") - self.assertTrue(item.text(1).startswith("lines_")) - self.assertEqual(item.text(2), "layer") - self.assertEqual(item.data(0, Qt.ItemDataRole.UserRole + 1), PredefinedGroup.No.value) - self.assertEqual(item.text(3), "") # Not used, just to test - - self.assertFalse(lizmap.dlg.list_group_visibility.isEnabled()) - - # Click the first line - lizmap.dlg.layer_tree.setCurrentItem(lizmap.dlg.layer_tree.topLevelItem(0)) - - # Fill the ACL field - self.assertTrue(lizmap.dlg.list_group_visibility.isEnabled()) - acl_layer = "a_group_id" - lizmap.dlg.list_group_visibility.setText(acl_layer) - lizmap.save_value_layer_group_data("group_visibility") - - # Fill the abstract field - html_abstract = "Hello" - lizmap.dlg.teLayerAbstract.setPlainText(html_abstract) - lizmap.save_value_layer_group_data("abstract") - - # Click the group base-layers - group_item = lizmap.dlg.layer_tree.findItems( - "baselayers", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 - )[0] - lizmap.dlg.layer_tree.setCurrentItem(group_item) - self.assertFalse(lizmap.dlg.panel_layer_all_settings.isEnabled()) - - # Click the group project-background-color - group_item = lizmap.dlg.layer_tree.findItems( - "project-background-color", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 - )[0] - lizmap.dlg.layer_tree.setCurrentItem(group_item) - self.assertTrue(lizmap.dlg.panel_layer_all_settings.isEnabled()) - self.assertTrue(lizmap.dlg.group_layer_metadata.isEnabled()) - self.assertFalse(lizmap.dlg.group_layer_tree_options.isEnabled()) - - # Click the group hidden - group_item = lizmap.dlg.layer_tree.findItems( - "hidden", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 - )[0] - lizmap.dlg.layer_tree.setCurrentItem(group_item) - # It should work, maybe the test click and click in the UI is missing one thing - # self.assertEqual(PredefinedGroup.Hidden.value, lizmap._current_item_predefined_group()) - # self.assertFalse(lizmap.dlg.panel_layer_all_settings.isEnabled()) - - # Back to a layer outside of these groups - group_item = lizmap.dlg.layer_tree.findItems( - "lines", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 - )[0] - lizmap.dlg.layer_tree.setCurrentItem(group_item) - self.assertTrue(lizmap.dlg.list_group_visibility.isEnabled()) - - # Check new values in the output config - output = lizmap.project_config_file( - LwcVersions.latest(), - check_server=False, - ignore_error=True, - ) - - # Layers - self.assertListEqual(output["layers"]["lines"]["group_visibility"], [acl_layer]) - self.assertEqual(output["layers"]["lines"]["abstract"], html_abstract) - # Predefined groups, still in the CFG - self.assertListEqual(output["layers"]["baselayers"]["group_visibility"], []) - self.assertEqual(output["layers"]["baselayers"]["abstract"], "") - self.assertListEqual(output["layers"]["project-background-color"]["group_visibility"], []) - self.assertEqual(output["layers"]["project-background-color"]["abstract"], "") - self.assertEqual(output["options"].get("default_background_color_index"), 0) - - self.assertFalse(output["layers"]["lines"].get("children_lizmap_features_table")) - self.assertEqual("False", output["layers"]["lines"].get("popupDisplayChildren")) - - # Test a false value as a string which shouldn't be there by default - self.assertIsNone(output["layers"]["lines"].get("externalWmsToggle")) - self.assertIsNone(output["layers"]["lines"].get("metatileSize")) - - def test_default_options_values_3_6(self, data: Path): - """Test default options values.""" - lizmap = self._setup_empty_project(data) - - output = lizmap.project_config_file(LwcVersions.Lizmap_3_6, check_server=False, ignore_error=True) - - # generic options - self.assertIsNone(output["options"].get("hideProject")) - self.assertFalse(output["options"].get("automatic_permalink")) - self.assertFalse(output["options"].get("wms_single_request_for_all_layers")) - self.assertIsNone(output["options"].get("acl")) - - # map tools - self.assertFalse(output["options"].get("measure")) - self.assertFalse(output["options"].get("print")) # The checkbox is removed since LWC 3.7.0 - self.assertFalse(output["options"].get("zoomHistory")) # The checkbox is removed since LWC 3.8.0 - self.assertFalse(output["options"].get("geolocation")) - self.assertFalse(output["options"].get("draw")) - self.assertIsNone(output["options"].get("externalSearch")) - self.assertEqual(25, output["options"].get("pointTolerance")) - self.assertEqual(10, output["options"].get("lineTolerance")) - self.assertEqual(5, output["options"].get("polygonTolerance")) - - # API keys - self.assertIsNone(output["options"].get("googleKey")) - self.assertIsNone(output["options"].get("bingKey")) - self.assertIsNone(output["options"].get("ignKey")) - - # Scales - self.assertFalse(output["options"].get("use_native_zoom_levels")) - self.assertFalse(output["options"].get("hide_numeric_scale_value")) - self.assertEqual([10000, 25000, 50000, 100000, 250000, 500000], output["options"].get("mapScales")) - self.assertEqual(1, output["options"].get("minScale")) - self.assertEqual(1000000000, output["options"].get("maxScale")) - self.assertIsNone(output["options"].get("max_scale_points")) - self.assertIsNone(output["options"].get("max_scale_lines_polygons")) - - # Map interface - self.assertFalse(output["options"].get("hideHeader")) - self.assertFalse(output["options"].get("hideMenu")) - self.assertFalse(output["options"].get("hideLegend")) - self.assertFalse(output["options"].get("hideOverview")) - self.assertFalse(output["options"].get("hideNavbar")) - self.assertEqual("dock", output["options"].get("popupLocation")) - self.assertTrue(output["options"].get("fixed_scale_overview_map")) - - # Layers page - self.assertIsNone(output["options"].get("hideGroupCheckbox")) - self.assertIsNone(output["options"].get("activateFirstMapTheme")) - - # Baselayers page - self.assertIsNone(output["options"].get("emptyBaselayer")) - self.assertIsNone(output["options"].get("startupBaselayer")) - - # Attribute page - self.assertIsNone(output["options"].get("limitDataToBbox")) - - # Layouts page - self.assertFalse(output["options"].get("default_popup_print")) - - # Dataviz page - self.assertIsNone(output["options"].get("datavizTemplate")) - self.assertIsNone(output["options"].get("dataviz_drag_drop")) - self.assertEqual("dock", output["options"].get("datavizLocation")) - self.assertIsNone(output["options"].get("theme")) # default value "dark" is not set - - # Time manager page - self.assertEqual(10, output["options"].get("tmTimeFrameSize")) - self.assertEqual("seconds", output["options"].get("tmTimeFrameType")) - self.assertEqual(1000, output["options"].get("tmAnimationFrameLength")) - - # Atlas page - self.assertIsNone(output["options"].get("atlasShowAtStartup")) - self.assertIsNone(output["options"].get("atlasAutoPlay")) - - def test_default_options_values_3_7(self, data: Path): - """Test default options values.""" - lizmap = self._setup_empty_project(data) - - output = lizmap.project_config_file(LwcVersions.Lizmap_3_7, check_server=False, ignore_error=True) - - # generic options - self.assertIsNone(output["options"].get("hideProject")) - self.assertFalse(output["options"].get("automatic_permalink")) - self.assertFalse(output["options"].get("wms_single_request_for_all_layers")) - self.assertIsNone(output["options"].get("acl")) - - # map tools - self.assertFalse(output["options"].get("measure")) - self.assertIsNone(output["options"].get("print")) # The checkbox is removed since LWC 3.7.0 - self.assertFalse(output["options"].get("zoomHistory")) # The checkbox is removed since LWC 3.8.0 - self.assertFalse(output["options"].get("geolocation")) - self.assertFalse(output["options"].get("draw")) - self.assertIsNone(output["options"].get("externalSearch")) - self.assertEqual(25, output["options"].get("pointTolerance")) - self.assertEqual(10, output["options"].get("lineTolerance")) - self.assertEqual(5, output["options"].get("polygonTolerance")) - - # API keys - self.assertIsNone(output["options"].get("googleKey")) - self.assertIsNone(output["options"].get("bingKey")) - self.assertIsNone(output["options"].get("ignKey")) - - # Scales - self.assertFalse(output["options"].get("use_native_zoom_levels")) - self.assertFalse(output["options"].get("hide_numeric_scale_value")) - self.assertEqual([10000, 25000, 50000, 100000, 250000, 500000], output["options"].get("mapScales")) - self.assertEqual(1, output["options"].get("minScale")) - self.assertEqual(1000000000, output["options"].get("maxScale")) - self.assertIsNone(output["options"].get("max_scale_points")) - self.assertIsNone(output["options"].get("max_scale_lines_polygons")) - - # Map interface - self.assertFalse(output["options"].get("hideHeader")) - self.assertFalse(output["options"].get("hideMenu")) - self.assertFalse(output["options"].get("hideLegend")) - self.assertFalse(output["options"].get("hideOverview")) - self.assertFalse(output["options"].get("hideNavbar")) - self.assertEqual("dock", output["options"].get("popupLocation")) - self.assertTrue(output["options"].get("fixed_scale_overview_map")) - - # Layers page - self.assertIsNone(output["options"].get("hideGroupCheckbox")) - self.assertIsNone(output["options"].get("activateFirstMapTheme")) - - # Baselayers page - self.assertIsNone(output["options"].get("emptyBaselayer")) - self.assertIsNone(output["options"].get("startupBaselayer")) - - # Attribute page - self.assertIsNone(output["options"].get("limitDataToBbox")) - - # Layouts page - self.assertFalse(output["options"].get("default_popup_print")) - - # Dataviz page - self.assertIsNone(output["options"].get("datavizTemplate")) - self.assertIsNone(output["options"].get("dataviz_drag_drop")) - self.assertEqual("dock", output["options"].get("datavizLocation")) - self.assertIsNone(output["options"].get("theme")) # default value "dark" is not set - - # Time manager page - self.assertEqual(10, output["options"].get("tmTimeFrameSize")) - self.assertEqual("seconds", output["options"].get("tmTimeFrameType")) - self.assertEqual(1000, output["options"].get("tmAnimationFrameLength")) - - # Atlas page - self.assertIsNone(output["options"].get("atlasShowAtStartup")) - self.assertIsNone(output["options"].get("atlasAutoPlay")) - - def test_default_options_values_3_8(self, data: Path): - """Test default options values.""" - lizmap = self._setup_empty_project(data) - - output = lizmap.project_config_file(LwcVersions.Lizmap_3_8, check_server=False, ignore_error=True) - - # generic options - self.assertIsNone(output["options"].get("hideProject")) - self.assertFalse(output["options"].get("automatic_permalink")) - self.assertFalse(output["options"].get("wms_single_request_for_all_layers")) - self.assertIsNone(output["options"].get("acl")) - - # map tools - self.assertFalse(output["options"].get("measure")) - self.assertIsNone(output["options"].get("print")) # The checkbox is removed since LWC 3.7.0 - self.assertIsNone(output["options"].get("zoomHistory")) # The checkbox is removed since LWC 3.8.0 - self.assertFalse(output["options"].get("geolocation")) - self.assertFalse(output["options"].get("draw")) - self.assertIsNone(output["options"].get("externalSearch")) - self.assertEqual(25, output["options"].get("pointTolerance")) - self.assertEqual(10, output["options"].get("lineTolerance")) - self.assertEqual(5, output["options"].get("polygonTolerance")) - - # API keys - self.assertIsNone(output["options"].get("googleKey")) - self.assertIsNone(output["options"].get("bingKey")) - self.assertIsNone(output["options"].get("ignKey")) - - # Scales - self.assertFalse(output["options"].get("use_native_zoom_levels")) - self.assertFalse(output["options"].get("hide_numeric_scale_value")) - self.assertEqual([10000, 25000, 50000, 100000, 250000, 500000], output["options"].get("mapScales")) - self.assertEqual(1, output["options"].get("minScale")) - self.assertEqual(1000000000, output["options"].get("maxScale")) - self.assertIsNone(output["options"].get("max_scale_points")) - self.assertIsNone(output["options"].get("max_scale_lines_polygons")) - - # Map interface - self.assertFalse(output["options"].get("hideHeader")) - self.assertFalse(output["options"].get("hideMenu")) - self.assertFalse(output["options"].get("hideLegend")) - self.assertFalse(output["options"].get("hideOverview")) - self.assertFalse(output["options"].get("hideNavbar")) - self.assertEqual("dock", output["options"].get("popupLocation")) - self.assertTrue(output["options"].get("fixed_scale_overview_map")) - - # Layers page - self.assertIsNone(output["options"].get("hideGroupCheckbox")) - self.assertIsNone(output["options"].get("activateFirstMapTheme")) - - # Baselayers page - self.assertIsNone(output["options"].get("emptyBaselayer")) - self.assertIsNone(output["options"].get("startupBaselayer")) - - # Attribute page - self.assertIsNone(output["options"].get("limitDataToBbox")) - - # Layouts page - self.assertFalse(output["options"].get("default_popup_print")) - - # Dataviz page - self.assertIsNone(output["options"].get("datavizTemplate")) - self.assertIsNone(output["options"].get("dataviz_drag_drop")) - self.assertEqual("dock", output["options"].get("datavizLocation")) - self.assertIsNone(output["options"].get("theme")) # default value "dark" is not set - - # Time manager page - self.assertEqual(10, output["options"].get("tmTimeFrameSize")) - self.assertEqual("seconds", output["options"].get("tmTimeFrameType")) - self.assertEqual(1000, output["options"].get("tmAnimationFrameLength")) - - # Atlas page - self.assertIsNone(output["options"].get("atlasShowAtStartup")) - self.assertIsNone(output["options"].get("atlasAutoPlay")) - - def test_default_options_values_3_9(self, data: Path): - """Test default options values.""" - lizmap = self._setup_empty_project(data) - - output = lizmap.project_config_file(LwcVersions.Lizmap_3_9, check_server=False, ignore_error=True) - - # generic options - self.assertIsNone(output["options"].get("hideProject")) - self.assertFalse(output["options"].get("automatic_permalink")) - self.assertFalse(output["options"].get("wms_single_request_for_all_layers")) - self.assertIsNone(output["options"].get("acl")) - - # map tools - self.assertFalse(output["options"].get("measure")) - self.assertIsNone(output["options"].get("print")) # The checkbox is removed since LWC 3.7.0 - self.assertIsNone(output["options"].get("zoomHistory")) # The checkbox is removed since LWC 3.8.0 - self.assertFalse(output["options"].get("geolocation")) - # self.assertIsNone(output["options"].get("geolocationPrecision")) # Added since LWC 3.10.0 - # self.assertIsNone(output["options"].get("geolocationDirection")) # Added since LWC 3.10.0 - self.assertFalse(output["options"].get("draw")) - self.assertIsNone(output["options"].get("externalSearch")) - self.assertEqual(25, output["options"].get("pointTolerance")) - self.assertEqual(10, output["options"].get("lineTolerance")) - self.assertEqual(5, output["options"].get("polygonTolerance")) - - # API keys - self.assertIsNone(output["options"].get("googleKey")) - self.assertIsNone(output["options"].get("bingKey")) - self.assertIsNone(output["options"].get("ignKey")) - - # Scales - self.assertFalse(output["options"].get("use_native_zoom_levels")) - self.assertFalse(output["options"].get("hide_numeric_scale_value")) - self.assertEqual([10000, 25000, 50000, 100000, 250000, 500000], output["options"].get("mapScales")) - self.assertEqual(1, output["options"].get("minScale")) - self.assertEqual(1000000000, output["options"].get("maxScale")) - self.assertIsNone(output["options"].get("max_scale_points")) - self.assertIsNone(output["options"].get("max_scale_lines_polygons")) - - # Map interface - self.assertFalse(output["options"].get("hideHeader")) - self.assertFalse(output["options"].get("hideMenu")) - self.assertFalse(output["options"].get("hideLegend")) - self.assertFalse(output["options"].get("hideOverview")) - self.assertFalse(output["options"].get("hideNavbar")) - self.assertEqual("dock", output["options"].get("popupLocation")) - self.assertTrue(output["options"].get("fixed_scale_overview_map")) - - # Layers page - self.assertIsNone(output["options"].get("hideGroupCheckbox")) - self.assertIsNone(output["options"].get("activateFirstMapTheme")) - - # Baselayers page - self.assertIsNone(output["options"].get("emptyBaselayer")) - self.assertIsNone(output["options"].get("startupBaselayer")) - - # Attribute page - self.assertIsNone(output["options"].get("limitDataToBbox")) - - # Layouts page - self.assertFalse(output["options"].get("default_popup_print")) - - # Dataviz page - self.assertIsNone(output["options"].get("datavizTemplate")) - self.assertIsNone(output["options"].get("dataviz_drag_drop")) - self.assertEqual("dock", output["options"].get("datavizLocation")) - self.assertIsNone(output["options"].get("theme")) # default value "dark" is not set - - # Time manager page - self.assertEqual(10, output["options"].get("tmTimeFrameSize")) - self.assertEqual("seconds", output["options"].get("tmTimeFrameType")) - self.assertEqual(1000, output["options"].get("tmAnimationFrameLength")) - - # Atlas page - self.assertIsNone(output["options"].get("atlasShowAtStartup")) - self.assertIsNone(output["options"].get("atlasAutoPlay")) - - def test_default_options_latest(self, data: Path): - """Test default options values.""" - lizmap = self._setup_empty_project(data) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - - # generic options - self.assertIsNone(output["options"].get("hideProject")) - self.assertFalse(output["options"].get("automatic_permalink")) - self.assertFalse(output["options"].get("wms_single_request_for_all_layers")) - self.assertIsNone(output["options"].get("acl")) - - # map tools - self.assertFalse(output["options"].get("measure")) - self.assertIsNone(output["options"].get("print")) # The checkbox is removed since LWC 3.7.0 - self.assertIsNone(output["options"].get("zoomHistory")) # The checkbox is removed since LWC 3.8.0 - self.assertFalse(output["options"].get("geolocation")) - self.assertTrue(output["options"].get("geolocationPrecision")) # Added since LWC 3.10.0 - self.assertFalse(output["options"].get("geolocationDirection")) # Added since LWC 3.10.0 - self.assertFalse(output["options"].get("draw")) - self.assertIsNone(output["options"].get("externalSearch")) - self.assertEqual(25, output["options"].get("pointTolerance")) - self.assertEqual(10, output["options"].get("lineTolerance")) - self.assertEqual(5, output["options"].get("polygonTolerance")) - - # API keys - self.assertIsNone(output["options"].get("googleKey")) - self.assertIsNone(output["options"].get("bingKey")) - self.assertIsNone(output["options"].get("ignKey")) - - # Scales - self.assertFalse(output["options"].get("use_native_zoom_levels")) - self.assertFalse(output["options"].get("hide_numeric_scale_value")) - self.assertEqual([10000, 25000, 50000, 100000, 250000, 500000], output["options"].get("mapScales")) - self.assertEqual(1, output["options"].get("minScale")) - self.assertEqual(1000000000, output["options"].get("maxScale")) - self.assertIsNone(output["options"].get("max_scale_points")) - self.assertIsNone(output["options"].get("max_scale_lines_polygons")) - - # Map interface - self.assertFalse(output["options"].get("hideHeader")) - self.assertFalse(output["options"].get("hideMenu")) - self.assertFalse(output["options"].get("hideLegend")) - self.assertFalse(output["options"].get("hideOverview")) - self.assertFalse(output["options"].get("hideNavbar")) - self.assertEqual("dock", output["options"].get("popupLocation")) - self.assertTrue(output["options"].get("fixed_scale_overview_map")) - - # Layers page - self.assertIsNone(output["options"].get("hideGroupCheckbox")) - self.assertIsNone(output["options"].get("activateFirstMapTheme")) - - # Baselayers page - self.assertIsNone(output["options"].get("emptyBaselayer")) - self.assertIsNone(output["options"].get("startupBaselayer")) - - # Attribute page - self.assertIsNone(output["options"].get("limitDataToBbox")) - - # Layouts page - self.assertFalse(output["options"].get("default_popup_print")) - - # Dataviz page - self.assertIsNone(output["options"].get("datavizTemplate")) - self.assertIsNone(output["options"].get("dataviz_drag_drop")) - self.assertEqual("dock", output["options"].get("datavizLocation")) - self.assertIsNone(output["options"].get("theme")) # default value "dark" is not set - - # Time manager page - self.assertEqual(10, output["options"].get("tmTimeFrameSize")) - self.assertEqual("seconds", output["options"].get("tmTimeFrameType")) - self.assertEqual(1000, output["options"].get("tmAnimationFrameLength")) - - # Atlas page - self.assertIsNone(output["options"].get("atlasShowAtStartup")) - self.assertIsNone(output["options"].get("atlasAutoPlay")) - - def test_max_scale_lwc_3_7(self, data: Path): - """Test about maximum scale when zooming.""" - lizmap = self._setup_empty_project(data, LwcVersions.Lizmap_3_6) - - self.assertEqual(5000.0, lizmap.dlg.max_scale_points.scale()) - self.assertEqual(5000.0, lizmap.dlg.max_scale_lines_polygons.scale()) - - # Max scale when zoomin - # Only points with a different value - lizmap.dlg.max_scale_points.setScale(1000.0) - - # Check new values in the output config - output = lizmap.project_config_file( - LwcVersions.latest(), - check_server=False, - ignore_error=True, - ) - - # Check scales in the CFG - self.assertEqual(1000.0, output["options"]["max_scale_points"]) - self.assertIsNone(output["options"].get("max_scale_lines_polygons")) - - def test_general_scales_properties_lwc_3_6(self, data: Path): - """Test some UI settings about general properties with LWC 3.6.""" - lizmap = self._setup_empty_project(data, LwcVersions.Lizmap_3_6) - - # Check default values - self.assertEqual("10000, 25000, 50000, 100000, 250000, 500000", lizmap.dlg.list_map_scales.text()) - - # Default values from config.py at the beginning only - self.assertEqual("1", lizmap.dlg.minimum_scale.text()) - self.assertEqual("1000000000", lizmap.dlg.maximum_scale.text()) - - # Trigger the signal - lizmap.get_min_max_scales() - - # Values from the UI - self.assertEqual("10000", lizmap.dlg.minimum_scale.text()) - self.assertEqual("500000", lizmap.dlg.maximum_scale.text()) - - scales = "1000, 5000, 15000" - - # Fill scales - lizmap.dlg.list_map_scales.setText(scales) - lizmap.get_min_max_scales() - self.assertEqual("1000", lizmap.dlg.minimum_scale.text()) - self.assertEqual("15000", lizmap.dlg.maximum_scale.text()) - self.assertEqual(scales, lizmap.dlg.list_map_scales.text()) - - # Check new values in the output config - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - - # Check scales in the CFG - self.assertEqual(1000, output["options"]["minScale"]) - self.assertEqual(15000, output["options"]["maxScale"]) - self.assertListEqual([1000, 5000, 15000], output["options"]["mapScales"]) - - # Project is in EPSG:2154, must be False - self.assertFalse(output["options"]["use_native_zoom_levels"]) - - # Check an empty list and a populated list then - self.assertIsNone(output["options"].get("acl")) - lizmap.dlg.inAcl.setText("cadastre,urbanism") - - output = lizmap.project_config_file( - LwcVersions.latest(), - check_server=False, - ignore_error=True, - ) - self.assertListEqual(["cadastre", "urbanism"], output["options"].get("acl")) - - def test_read_existing_lwc_3_6_to_3_7(self, data: Path): - """Test to read a CFG 3.6 and to export it to 3.7 about scales.""" - # Checking CFG before opening the QGS file - with data.joinpath("3857_project_lwc_3_6.qgs.cfg").open() as f: - json_data = json.load(f) - self.assertListEqual([1000, 5000, 10000, 500000], json_data["options"]["mapScales"]) - - project = QgsProject.instance() - project.read(str(data.joinpath("3857_project_lwc_3_6.qgs"))) - self.assertEqual(1, len(project.mapLayers())) - - lizmap = Lizmap(get_iface(), lwc_version=LwcVersions.Lizmap_3_7) - # read_cfg_file will call "layers_config_file" - lizmap.read_cfg_file(skip_tables=True) - - self.assertEqual("1000", lizmap.dlg.minimum_scale.text()) - self.assertEqual("500000", lizmap.dlg.maximum_scale.text()) - self.assertEqual("1000, 5000, 10000, 500000", lizmap.dlg.list_map_scales.text()) - - output = lizmap.project_config_file( - LwcVersions.Lizmap_3_7, - check_server=False, - ignore_error=True, - ) - - # Project is in EPSG:3857, must be True - self.assertTrue(output["options"]["use_native_zoom_levels"]) - # only two when we save - self.assertListEqual([1000, 500000], output["options"]["mapScales"]) - self.assertEqual(1000, output["options"]["minScale"]) - self.assertEqual(500000, output["options"]["maxScale"]) - - def test_read_existing_lwc_3_6_to_3_6(self, data: Path): - """Test to read a CFG 3.6 and to export it to 3.6 about scales.""" - # Checking CFG before opening the QGS file - project = QgsProject.instance() - project.read(str(data.joinpath("3857_project_lwc_3_6.qgs"))) - self.assertEqual(1, len(project.mapLayers())) - - lizmap = Lizmap(get_iface(), lwc_version=LwcVersions.Lizmap_3_6) - # read_cfg_file will call "layers_config_file" - lizmap.read_cfg_file(skip_tables=True) - - self.assertEqual("1000", lizmap.dlg.minimum_scale.text()) - self.assertEqual("500000", lizmap.dlg.maximum_scale.text()) - self.assertEqual("1000, 5000, 10000, 500000", lizmap.dlg.list_map_scales.text()) - - output = lizmap.project_config_file( - LwcVersions.Lizmap_3_6, - check_server=False, - ignore_error=True, - ) - - # Project is in EPSG:3857, must be False because of LWC 3.6 - self.assertFalse(output["options"]["use_native_zoom_levels"]) - - self.assertListEqual([1000, 5000, 10000, 500000], output["options"]["mapScales"]) - self.assertEqual(1000, output["options"]["minScale"]) - self.assertEqual(500000, output["options"]["maxScale"]) - - def test_atlas_auto_play_true_values(self, data: Path): - """Test some UI settings about boolean values.""" - lizmap = self._setup_empty_project(data) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - self.assertIsNone(output["options"].get("atlasAutoPlay")) - - lizmap.dlg.atlasAutoPlay.setChecked(True) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - self.assertTrue(output["options"].get("atlasAutoPlay")) - - lizmap.dlg.atlasAutoPlay.setChecked(False) - - output = lizmap.project_config_file( - LwcVersions.latest(), - check_server=False, - ignore_error=True, - ) - - self.assertIsNone(output["options"].get("atlasAutoPlay")) - - def test_geolocation_values(self, data: Path): - """Test geolocation UI settings.""" - lizmap = self._setup_empty_project(data) - - # Default geolocation checkboxes checked - self.assertFalse(lizmap.dlg.groupbox_geolocation.isChecked()) - self.assertTrue(lizmap.dlg.checkbox_geolocation_precision.isChecked()) - self.assertFalse(lizmap.dlg.checkbox_geolocation_direction.isChecked()) - - # Default geolocation checkboxes enabled - self.assertTrue(lizmap.dlg.groupbox_geolocation.isEnabled()) - self.assertFalse(lizmap.dlg.checkbox_geolocation_precision.isEnabled()) - self.assertFalse(lizmap.dlg.checkbox_geolocation_direction.isEnabled()) - - # Check geolocation checkbox to enable other checkboxes - lizmap.dlg.groupbox_geolocation.setChecked(True) - self.assertTrue(lizmap.dlg.checkbox_geolocation_precision.isEnabled()) - self.assertTrue(lizmap.dlg.checkbox_geolocation_direction.isEnabled()) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # options - self.assertTrue(output["options"].get("geolocation")) - self.assertTrue(output["options"].get("geolocationPrecision")) - self.assertFalse(output["options"].get("geolocationDirection")) - - # Check direction - lizmap.dlg.checkbox_geolocation_direction.setChecked(True) - # Uncheck precision - lizmap.dlg.checkbox_geolocation_precision.setChecked(False) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # options - self.assertTrue(output["options"].get("geolocation")) - self.assertFalse(output["options"].get("geolocationPrecision")) - self.assertTrue(output["options"].get("geolocationDirection")) - - def test_exclude_basemaps_from_single_wms_values(self, data: Path): - """Test exclude basemaps from single WMS UI settings.""" - lizmap = self._setup_empty_project(data) - - # Default checkbox states - self.assertFalse(lizmap.dlg.checkbox_wms_single_request_all_layers.isChecked()) - self.assertFalse(lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isChecked()) - - # Exclude basemaps checkbox should be disabled when single WMS is off - self.assertFalse(lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isEnabled()) - - # Enable single WMS - exclude basemaps checkbox should become enabled - lizmap.dlg.checkbox_wms_single_request_all_layers.setChecked(True) - self.assertTrue(lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isEnabled()) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # Single WMS enabled, exclude basemaps still unchecked - self.assertTrue(output["options"].get("wms_single_request_for_all_layers")) - self.assertFalse(output["options"].get("exclude_basemaps_from_single_wms")) - - # Enable exclude basemaps - lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.setChecked(True) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # Both options enabled - self.assertTrue(output["options"].get("wms_single_request_for_all_layers")) - self.assertTrue(output["options"].get("exclude_basemaps_from_single_wms")) - - # Disable single WMS - exclude basemaps checkbox should become disabled - lizmap.dlg.checkbox_wms_single_request_all_layers.setChecked(False) - self.assertFalse(lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isEnabled()) - - def test_group_popup_by_layer(self, data: Path): - """Test group popup by layer UI settings.""" - lizmap = self._setup_empty_project(data) - - # Default checkbox states - self.assertFalse(lizmap.dlg.checkbox_group_popup_by_layer.isChecked()) - - # Enable group popup by layer - lizmap.dlg.checkbox_group_popup_by_layer.setChecked(True) - self.assertTrue(lizmap.dlg.checkbox_group_popup_by_layer.isEnabled()) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # Group popup by layers option should be checked in output config file - self.assertTrue(output["options"].get("group_popup_by_layer")) - - # Disable group popup by layer - lizmap.dlg.checkbox_group_popup_by_layer.setChecked(False) - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # Group popup by layers option should be unchecked in output config file - self.assertFalse(output["options"].get("group_popup_by_layer")) - - def test_short_link_permalink(self, data: Path): - """Test short link permalink UI settings.""" - lizmap = self._setup_empty_project(data) - - # Default checkbox states - self.assertFalse(lizmap.dlg.checkbox_short_link_permalink.isChecked()) - - # Enable short link permalink - lizmap.dlg.checkbox_short_link_permalink.setChecked(True) - self.assertTrue(lizmap.dlg.checkbox_short_link_permalink.isEnabled()) - - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # Short link permalink option should be checked in output config file - self.assertTrue(output["options"].get("short_link_permalink")) - - # Disable short link permalink - lizmap.dlg.checkbox_short_link_permalink.setChecked(False) - output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) - # Short link permalink option should be unchecked in output config file - self.assertFalse(output["options"].get("short_link_permalink")) +def test_ui_base(data: Path, qgis_iface: QgisInterface): + """Test opening the Lizmap dialog with some basic checks.""" + project = QgsProject.instance() + project.clear() + lizmap = Lizmap(qgis_iface, lwc_version=LwcVersions.latest()) + + layer = QgsVectorLayer(str(data.joinpath("lines.geojson")), "lines", "ogr") + project.addMapLayer(layer) + + layer = QgsVectorLayer(str(data.joinpath("points.geojson")), "points", "ogr") + project.addMapLayer(layer) + + flag, message = lizmap.check_global_project_options() + assert not flag, message + assert message, ( + "You need to open a QGIS project, using the QGS extension.
This is " + "needed before using other tabs in ""the plugin." + ) + + project.write(str(data.joinpath("unittest.qgs"))) + flag, message = lizmap.check_global_project_options() + assert flag, message + + # lizmap.run() + # lizmap.get_map_options() + +def test_legend_options(data: Path, qgis_iface: QgisInterface): + """Test about reading legend options.""" + project = QgsProject.instance() + project.read(str(data.joinpath("legend_image_option.qgs"))) + assert len(project.mapLayers()) == 3 + + lizmap = Lizmap(qgis_iface, lwc_version=LwcVersions.latest()) + # read_cfg_file will call "layers_config_file" + config = lizmap.read_cfg_file(skip_tables=True) + print("\n::test_legend_options::config", config) + + lizmap.process_node(lizmap.layerList, project.layerTreeRoot(), None, config) + + assert lizmap.dlg.minimum_scale.text() == "5000" + assert lizmap.dlg.maximum_scale.text() == "500000" + assert lizmap.dlg.list_map_scales.text() == "5000, 250000, 500000" + + assert lizmap.layerList.get("legend_disabled_layer_id").get("legend_image_option") == "disabled" + assert lizmap.layerList["legend_displayed_startup_layer_id"]["legend_image_option"] == "expand_at_startup" + assert lizmap.layerList["legend_hidden_startup_layer_id"]["legend_image_option"] == "hide_at_startup" + + # For LWC 3.6 + output = lizmap.project_config_file( + LwcVersions.Lizmap_3_6, + check_server=False, + ignore_error=True, + ) + + # NOTE: Seems that HTML widget not working in tests + # See lizmap.widgets.html_editor line 117 + # assert '
' in output['options']['datavizTemplate'] + + assert output["layers"]["legend_displayed_startup"]["legend_image_option"] == "expand_at_startup" + assert output["layers"]["legend_displayed_startup"].get("noLegendImage") is None + assert output["options"].get("default_background_color_index") is None + + # For LWC 3.5 + output = lizmap.project_config_file( + LwcVersions.Lizmap_3_5, with_gui=False, check_server=False, ignore_error=True + ) + assert output["layers"]["legend_displayed_startup"].get("legend_image_option") is None + assert str(False) == output["layers"]["legend_displayed_startup"]["noLegendImage"] + +def _setup_empty_project( + data: Path, + qgis_iface: QgisInterface, + lwc_version: LwcVersions = LwcVersions.latest(), +) -> Lizmap: + """Internal function to add a layer and a basic check.""" + project = QgsProject.instance() + layer = QgsVectorLayer(str(data.joinpath("lines.geojson")), "lines", "ogr") + project.addMapLayer(layer) + project.setFileName(temporary_file_path()) + + lizmap = Lizmap(qgis_iface, lwc_version=lwc_version) + baselayers = lizmap._add_group_legend("baselayers", exclusive=True, parent=None, project=project) + lizmap._add_group_legend( + "project-background-color", exclusive=False, parent=baselayers, project=project + ) + hidden = lizmap._add_group_legend("hidden", project=project) + + # For testing, we add OSM as hidden layer + hidden_raster = QgsRasterLayer( + "type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OSM", "wms" + ) + project.addMapLayer(hidden_raster, False) + hidden.addLayer(hidden_raster) + + # Do not use read_lizmap_config_file + # as it will be called by read_cfg_file and also the UI is set in read_cfg_file + config = lizmap.read_cfg_file(skip_tables=True) + + lizmap.dlg.widget_initial_extent.setOutputExtentFromLayer(layer) + + # Config is empty in the CFG file because it's a new project + TestCase.assertDictEqual({}, config) + + # Some process + lizmap.process_node(lizmap.layerList, project.layerTreeRoot(), None, {}) + + return lizmap + +def test_lizmap_layer_properties(data: Path, qgis_iface: QgisInterface): + """Test apply some properties in a layer in the dialog.""" + lizmap = _setup_empty_project(data, qgis_iface) + + # Click the layer + item = lizmap.dlg.layer_tree.topLevelItem(0) + assert item.text(0) == "lines" + assert item.text(1).startswith("lines_") + assert item.text(2) == "layer" + assert item.data(0, Qt.ItemDataRole.UserRole + 1) == PredefinedGroup.No.value + assert item.text(3) == "" # Not used, just to test + + assert not lizmap.dlg.list_group_visibility.isEnabled(), "Visibility should be disabled" + + # Click the first line + lizmap.dlg.layer_tree.setCurrentItem(lizmap.dlg.layer_tree.topLevelItem(0)) + + # Fill the ACL field + assert lizmap.dlg.list_group_visibility.isEnabled(), "Visibility should be enabled" + acl_layer = "a_group_id" + lizmap.dlg.list_group_visibility.setText(acl_layer) + lizmap.save_value_layer_group_data("group_visibility") + + # Fill the abstract field + html_abstract = "Hello" + lizmap.dlg.teLayerAbstract.setPlainText(html_abstract) + lizmap.save_value_layer_group_data("abstract") + + # Click the group base-layers + group_item = lizmap.dlg.layer_tree.findItems( + "baselayers", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 + )[0] + lizmap.dlg.layer_tree.setCurrentItem(group_item) + assert not lizmap.dlg.panel_layer_all_settings.isEnabled() + + # Click the group project-background-color + group_item = lizmap.dlg.layer_tree.findItems( + "project-background-color", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 + )[0] + lizmap.dlg.layer_tree.setCurrentItem(group_item) + assert lizmap.dlg.panel_layer_all_settings.isEnabled() + assert lizmap.dlg.group_layer_metadata.isEnabled() + # XXX QGIS4 KO + assert not lizmap.dlg.group_layer_tree_options.isEnabled(), "Layer tree option should be disabled" + + # Click the group hidden + group_item = lizmap.dlg.layer_tree.findItems( + "hidden", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 + )[0] + lizmap.dlg.layer_tree.setCurrentItem(group_item) + # It should work, maybe the test click and click in the UI is missing one thing + # assert lizmap._current_item_predefined_group() == PredefinedGroup.Hidden.value + # assert not lizmap.dlg.panel_layer_all_settings.isEnabled() + + # Back to a layer outside of these groups + group_item = lizmap.dlg.layer_tree.findItems( + "lines", Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchRecursive, 0 + )[0] + lizmap.dlg.layer_tree.setCurrentItem(group_item) + assert lizmap.dlg.list_group_visibility.isEnabled() + + # Check new values in the output config + output = lizmap.project_config_file( + LwcVersions.latest(), + check_server=False, + ignore_error=True, + ) + + # Layers + assert output["layers"]["lines"]["group_visibility"] == [acl_layer] + assert output["layers"]["lines"]["abstract"] == html_abstract + # Predefined groups, still in the CFG + assert output["layers"]["baselayers"]["group_visibility"] == [] + assert output["layers"]["baselayers"]["abstract"] == "" + assert output["layers"]["project-background-color"]["group_visibility"] == [] + assert output["layers"]["project-background-color"]["abstract"] == "" + # XXX QGIS4 KO + assert output["options"]["default_background_color_index"] == 0 + + assert not output["layers"]["lines"].get("children_lizmap_features_table") + assert output["layers"]["lines"].get("popupDisplayChildren") == "False" + + # Test a false value as a string which shouldn't be there by default + assert output["layers"]["lines"].get("externalWmsToggle") is None + assert output["layers"]["lines"].get("metatileSize") is None + +def test_default_options_values_3_6(data: Path, qgis_iface: QgisInterface): + """Test default options values.""" + lizmap = _setup_empty_project(data, qgis_iface) + + output = lizmap.project_config_file(LwcVersions.Lizmap_3_6, check_server=False, ignore_error=True) + + # generic options + assert output["options"].get("hideProject") is None + assert not output["options"].get("automatic_permalink") + assert not output["options"].get("wms_single_request_for_all_layers") + assert output["options"].get("acl") is None + + # map tools + assert not output["options"].get("measure") + assert not output["options"].get("print") # The checkbox is removed since LWC 3.7.0 + assert not output["options"].get("zoomHistory") # The checkbox is removed since LWC 3.8.0 + assert not output["options"].get("geolocation") + assert not output["options"].get("draw") + assert output["options"].get("externalSearch") is None + assert output["options"]["pointTolerance"] == 25 + assert output["options"]["lineTolerance"] == 10 + assert output["options"]["polygonTolerance"] == 5 + + # API keys + assert output["options"].get("googleKey") is None + assert output["options"].get("bingKey") is None + assert output["options"].get("ignKey") is None + + # Scales + assert not output["options"].get("use_native_zoom_levels") + assert not output["options"].get("hide_numeric_scale_value") + assert output["options"]["mapScales"] == [10000, 25000, 50000, 100000, 250000, 500000] + assert output["options"]["minScale"] == 1 + assert output["options"]["maxScale"] == 1000000000 + assert output["options"].get("max_scale_points") is None + assert output["options"].get("max_scale_lines_polygons") is None + + # Map interface + assert not output["options"].get("hideHeader") + assert not output["options"].get("hideMenu") + assert not output["options"].get("hideLegend") + assert not output["options"].get("hideOverview") + assert not output["options"].get("hideNavbar") + assert output["options"]["popupLocation"] == "dock" + assert output["options"]["fixed_scale_overview_map"] + + # Layers page + assert output["options"].get("hideGroupCheckbox") is None + assert output["options"].get("activateFirstMapTheme") is None + + # Baselayers page + assert output["options"].get("emptyBaselayer") is None + assert output["options"].get("startupBaselayer") is None + + # Attribute page + assert output["options"].get("limitDataToBbox") is None + + # Layouts page + assert not output["options"].get("default_popup_print") + + # Dataviz page + assert output["options"].get("datavizTemplate") is None + assert output["options"].get("dataviz_drag_drop") is None + assert output["options"]["datavizLocation"] == "dock" + assert output["options"].get("theme") is None # default value "dark" is not set + + # Time manager page + assert output["options"]["tmTimeFrameSize"] == 10 + assert output["options"]["tmTimeFrameType"] == "seconds" + assert output["options"]["tmAnimationFrameLength"] == 1000 + + # Atlas page + assert output["options"].get("atlasShowAtStartup") is None + assert output["options"].get("atlasAutoPlay") is None + +def test_default_options_values_3_7(data: Path, qgis_iface: QgisInterface): + """Test default options values.""" + lizmap = _setup_empty_project(data, qgis_iface) + + output = lizmap.project_config_file(LwcVersions.Lizmap_3_7, check_server=False, ignore_error=True) + + # generic options + output["options"].get("hideProject") is None + assert not output["options"].get("automatic_permalink") + assert not output["options"].get("wms_single_request_for_all_layers") + assert output["options"].get("acl") is None + + # map tools + assert not output["options"].get("measure") + assert output["options"].get("print") is None # The checkbox is removed since LWC 3.7.0 + assert not output["options"].get("zoomHistory") # The checkbox is removed since LWC 3.8.0 + assert not output["options"].get("geolocation") + assert not output["options"].get("draw") + assert output["options"].get("externalSearch") is None + assert output["options"]["pointTolerance"] == 25 + assert output["options"]["lineTolerance"] == 10 + assert output["options"]["polygonTolerance"] == 5 + + # API keys + assert output["options"].get("googleKey") is None + assert output["options"].get("bingKey") is None + assert output["options"].get("ignKey") is None + + # Scales + assert not output["options"].get("use_native_zoom_levels") + assert not output["options"].get("hide_numeric_scale_value") + assert output["options"]["mapScales"] == [10000, 25000, 50000, 100000, 250000, 500000] + assert output["options"]["minScale"] == 1 + assert output["options"]["maxScale"] == 1000000000 + assert output["options"].get("max_scale_points") is None + assert output["options"].get("max_scale_lines_polygons") is None + + # Map interface + assert not output["options"].get("hideHeader") + assert not output["options"].get("hideMenu") + assert not output["options"].get("hideLegend") + assert not output["options"].get("hideOverview") + assert not output["options"].get("hideNavbar") + assert output["options"]["popupLocation"] == "dock" + assert output["options"].get("fixed_scale_overview_map") + + # Layers page + assert output["options"].get("hideGroupCheckbox") is None + assert output["options"].get("activateFirstMapTheme") is None + + # Baselayers page + assert output["options"].get("emptyBaselayer") is None + assert output["options"].get("startupBaselayer") is None + + # Attribute page + assert output["options"].get("limitDataToBbox") is None + + # Layouts page + assert not output["options"].get("default_popup_print") + + # Dataviz page + assert output["options"].get("datavizTemplate") is None + assert output["options"].get("dataviz_drag_drop") is None + assert output["options"]["datavizLocation"] == "dock" + assert output["options"].get("theme") is None # default value "dark" is not set + + # Time manager page + assert output["options"]["tmTimeFrameSize"] == 10 + assert output["options"]["tmTimeFrameType"] == "seconds" + assert output["options"]["tmAnimationFrameLength"] == 1000 + + # Atlas page + assert output["options"].get("atlasShowAtStartup") is None + assert output["options"].get("atlasAutoPlay") is None + +def test_default_options_values_3_8(data: Path, qgis_iface: QgisInterface): + """Test default options values.""" + lizmap = _setup_empty_project(data, qgis_iface) + + output = lizmap.project_config_file(LwcVersions.Lizmap_3_8, check_server=False, ignore_error=True) + + # generic options + assert output["options"].get("hideProject") is None + assert not output["options"].get("automatic_permalink") + assert not output["options"].get("wms_single_request_for_all_layers") + assert output["options"].get("acl") is None + + # map tools + assert not output["options"].get("measure") + assert output["options"].get("print") is None # The checkbox is removed since LWC 3.7.0 + assert output["options"].get("zoomHistory") is None # The checkbox is removed since LWC 3.8.0 + assert not output["options"].get("geolocation") + assert not output["options"].get("draw") + assert output["options"].get("externalSearch") is None + assert output["options"].get("pointTolerance") == 25 + assert output["options"].get("lineTolerance") == 10 + assert output["options"].get("polygonTolerance") == 5 + + # API keys + assert output["options"].get("googleKey") is None + assert output["options"].get("bingKey") is None + assert output["options"].get("ignKey") is None + + # Scales + assert not output["options"].get("use_native_zoom_levels") + assert not output["options"].get("hide_numeric_scale_value") + assert output["options"].get("mapScales") == [10000, 25000, 50000, 100000, 250000, 500000] + assert output["options"].get("minScale") == 1 + assert output["options"].get("maxScale") == 1000000000 + assert output["options"].get("max_scale_points") is None + assert output["options"].get("max_scale_lines_polygons") is None + + # Map interface + assert not output["options"].get("hideHeader") + assert not output["options"].get("hideMenu") + assert not output["options"].get("hideLegend") + assert not output["options"].get("hideOverview") + assert not output["options"].get("hideNavbar") + assert output["options"].get("popupLocation") == "dock" + assert output["options"].get("fixed_scale_overview_map") + + # Layers page + assert output["options"].get("hideGroupCheckbox") is None + assert output["options"].get("activateFirstMapTheme") is None + + # Baselayers page + assert output["options"].get("emptyBaselayer") is None + assert output["options"].get("startupBaselayer") is None + + # Attribute page + assert output["options"].get("limitDataToBbox") is None + + # Layouts page + assert not output["options"].get("default_popup_print") + + # Dataviz page + assert output["options"].get("datavizTemplate") is None + assert output["options"].get("dataviz_drag_drop") is None + assert output["options"].get("datavizLocation") == "dock" + assert output["options"].get("theme") is None # default value "dark" is not set + + # Time manager page + assert output["options"].get("tmTimeFrameSize") == 10 + assert output["options"].get("tmTimeFrameType") == "seconds" + assert output["options"].get("tmAnimationFrameLength") == 1000 + + # Atlas page + assert output["options"].get("atlasShowAtStartup") is None + assert output["options"].get("atlasAutoPlay") is None + +def test_default_options_values_3_9(data: Path, qgis_iface: QgisInterface): + """Test default options values.""" + lizmap = _setup_empty_project(data, qgis_iface) + + output = lizmap.project_config_file(LwcVersions.Lizmap_3_9, check_server=False, ignore_error=True) + + # generic options + assert output["options"].get("hideProject") is None + assert not output["options"].get("automatic_permalink") + assert not output["options"].get("wms_single_request_for_all_layers") + assert output["options"].get("acl") is None + + # map tools + assert not output["options"].get("measure") + assert output["options"].get("print") is None # The checkbox is removed since LWC 3.7.0 + assert output["options"].get("zoomHistory") is None # The checkbox is removed since LWC 3.8.0 + assert not output["options"].get("geolocation") + # assert output["options"].get("geolocationPrecision") is None # Added since LWC 3.10.0 + # assert output["options"].get("geolocationDirection") is None # Added since LWC 3.10.0 + assert not output["options"].get("draw") + assert output["options"].get("externalSearch") is None + assert output["options"].get("pointTolerance") == 25 + assert output["options"].get("lineTolerance") == 10 + assert output["options"].get("polygonTolerance") == 5 + + # API keys + assert output["options"].get("googleKey") is None + assert output["options"].get("bingKey") is None + assert output["options"].get("ignKey") is None + + # Scales + assert not output["options"].get("use_native_zoom_levels") + assert not output["options"].get("hide_numeric_scale_value") + assert output["options"].get("mapScales") == [10000, 25000, 50000, 100000, 250000, 500000] + assert output["options"].get("minScale") == 1 + assert output["options"].get("maxScale") == 1000000000 + assert output["options"].get("max_scale_points") is None + assert output["options"].get("max_scale_lines_polygons") is None + + # Map interface + assert not output["options"].get("hideHeader") + assert not output["options"].get("hideMenu") + assert not output["options"].get("hideLegend") + assert not output["options"].get("hideOverview") + assert not output["options"].get("hideNavbar") + assert output["options"].get("popupLocation") == "dock" + assert output["options"].get("fixed_scale_overview_map") + + # Layers page + assert output["options"].get("hideGroupCheckbox") is None + assert output["options"].get("activateFirstMapTheme") is None + + # Baselayers page + assert output["options"].get("emptyBaselayer") is None + assert output["options"].get("startupBaselayer") is None + + # Attribute page + assert output["options"].get("limitDataToBbox") is None + + # Layouts page + assert not output["options"].get("default_popup_print") + + # Dataviz page + assert output["options"].get("datavizTemplate") is None + assert output["options"].get("dataviz_drag_drop") is None + assert output["options"].get("datavizLocation") == "dock" + assert output["options"].get("theme") is None # default value "dark" is not set + + # Time manager page + assert output["options"].get("tmTimeFrameSize") == 10 + assert output["options"].get("tmTimeFrameType") == "seconds" + assert output["options"].get("tmAnimationFrameLength") == 1000 + + # Atlas page + assert output["options"].get("atlasShowAtStartup") is None + assert output["options"].get("atlasAutoPlay") is None + +def test_default_options_latest(data: Path, qgis_iface: QgisInterface): + """Test default options values.""" + lizmap = _setup_empty_project(data, qgis_iface) + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + + # generic options + assert output["options"].get("hideProject") is None + assert not output["options"].get("automatic_permalink") + assert not output["options"].get("wms_single_request_for_all_layers") + assert output["options"].get("acl") is None + + # map tools + assert not output["options"].get("measure") + assert output["options"].get("print") is None # The checkbox is removed since LWC 3.7.0 + assert output["options"].get("zoomHistory") is None # The checkbox is removed since LWC 3.8.0 + assert not output["options"].get("geolocation") + assert output["options"].get("geolocationPrecision") # Added since LWC 3.10.0 + assert not output["options"].get("geolocationDirection") # Added since LWC 3.10.0 + assert not output["options"].get("draw") + assert output["options"].get("externalSearch") is None + assert output["options"].get("pointTolerance") == 25 + assert output["options"].get("lineTolerance") == 10 + assert output["options"].get("polygonTolerance") == 5 + + # API keys + assert output["options"].get("googleKey") is None + assert output["options"].get("bingKey") is None + assert output["options"].get("ignKey") is None + + # Scales + assert not output["options"].get("use_native_zoom_levels") + assert not output["options"].get("hide_numeric_scale_value") + assert output["options"].get("mapScales") == [10000, 25000, 50000, 100000, 250000, 500000] + assert output["options"].get("minScale") == 1 + assert output["options"].get("maxScale") == 1000000000 + assert output["options"].get("max_scale_points") is None + assert output["options"].get("max_scale_lines_polygons") is None + + # Map interface + assert not output["options"].get("hideHeader") + assert not output["options"].get("hideMenu") + assert not output["options"].get("hideLegend") + assert not output["options"].get("hideOverview") + assert not output["options"].get("hideNavbar") + assert output["options"].get("popupLocation") == "dock" + assert output["options"].get("fixed_scale_overview_map") + + # Layers page + assert output["options"].get("hideGroupCheckbox") is None + assert output["options"].get("activateFirstMapTheme") is None + + # Baselayers page + assert output["options"].get("emptyBaselayer") is None + assert output["options"].get("startupBaselayer") is None + + # Attribute page + assert output["options"].get("limitDataToBbox") is None + + # Layouts page + assert not output["options"].get("default_popup_print") + + # Dataviz page + assert output["options"].get("datavizTemplate") is None + assert output["options"].get("dataviz_drag_drop") is None + assert output["options"].get("datavizLocation") == "dock" + assert output["options"].get("theme") is None # default value "dark" is not set + + # Time manager page + assert output["options"].get("tmTimeFrameSize") == 10 + assert output["options"].get("tmTimeFrameType") == "seconds" + assert output["options"].get("tmAnimationFrameLength") == 1000 + + # Atlas page + assert output["options"].get("atlasShowAtStartup") is None + assert output["options"].get("atlasAutoPlay") is None + +def test_max_scale_lwc_3_7(data: Path, qgis_iface: QgisInterface): + """Test about maximum scale when zooming.""" + lizmap = _setup_empty_project(data, qgis_iface, LwcVersions.Lizmap_3_6) + + assert lizmap.dlg.max_scale_points.scale() == 5000.0 + assert lizmap.dlg.max_scale_lines_polygons.scale() == 5000.0 + + # Max scale when zoomin + # Only points with a different value + lizmap.dlg.max_scale_points.setScale(1000.0) + + # Check new values in the output config + output = lizmap.project_config_file( + LwcVersions.latest(), + check_server=False, + ignore_error=True, + ) + + # Check scales in the CFG + assert output["options"]["max_scale_points"] == 1000.0 + assert output["options"].get("max_scale_lines_polygons") is None + +def test_general_scales_properties_lwc_3_6(data: Path, qgis_iface: QgisInterface): + """Test some UI settings about general properties with LWC 3.6.""" + lizmap = _setup_empty_project(data, qgis_iface, LwcVersions.Lizmap_3_6) + + # Check default values + assert lizmap.dlg.list_map_scales.text() == "10000, 25000, 50000, 100000, 250000, 500000" + + # Default values from config.py at the beginning only + assert lizmap.dlg.minimum_scale.text() == "1" + assert lizmap.dlg.maximum_scale.text() == "1000000000" + + # Trigger the signal + lizmap.get_min_max_scales() + + # Values from the UI + assert lizmap.dlg.minimum_scale.text() == "10000" + assert lizmap.dlg.maximum_scale.text() == "500000" + + scales = "1000, 5000, 15000" + + # Fill scales + lizmap.dlg.list_map_scales.setText(scales) + lizmap.get_min_max_scales() + assert lizmap.dlg.minimum_scale.text() == "1000" + assert lizmap.dlg.maximum_scale.text() == "15000" + assert lizmap.dlg.list_map_scales.text() == scales + + # Check new values in the output config + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + + # Check scales in the CFG + assert output["options"]["minScale"] == 1000 + assert output["options"]["maxScale"] == 15000 + assert output["options"]["mapScales"] == [1000, 5000, 15000] + + # Project is in EPSG:2154, must be False + assert not output["options"]["use_native_zoom_levels"] + + # Check an empty list and a populated list then + assert output["options"].get("acl") is None + lizmap.dlg.inAcl.setText("cadastre,urbanism") + + output = lizmap.project_config_file( + LwcVersions.latest(), + check_server=False, + ignore_error=True, + ) + TestCase.assertListEqual(["cadastre", "urbanism"], output["options"].get("acl")) + +def test_read_existing_lwc_3_6_to_3_7(data: Path, qgis_iface: QgisInterface): + """Test to read a CFG 3.6 and to export it to 3.7 about scales.""" + # Checking CFG before opening the QGS file + with data.joinpath("3857_project_lwc_3_6.qgs.cfg").open() as f: + json_data = json.load(f) + TestCase.assertListEqual([1000, 5000, 10000, 500000], json_data["options"]["mapScales"]) + + project = QgsProject.instance() + project.read(str(data.joinpath("3857_project_lwc_3_6.qgs"))) + assert len(project.mapLayers()) == 1 + + lizmap = Lizmap(qgis_iface, lwc_version=LwcVersions.Lizmap_3_7) + # read_cfg_file will call "layers_config_file" + lizmap.read_cfg_file(skip_tables=True) + + assert lizmap.dlg.minimum_scale.text() == "1000" + assert lizmap.dlg.maximum_scale.text() == "500000" + assert lizmap.dlg.list_map_scales.text() == "1000, 5000, 10000, 500000" + + output = lizmap.project_config_file( + LwcVersions.Lizmap_3_7, + check_server=False, + ignore_error=True, + ) + + # Project is in EPSG:3857, must be True + assert output["options"]["use_native_zoom_levels"] + # only two when we save + assert output["options"]["mapScales"] == [1000, 500000] + assert output["options"]["minScale"] == 1000 + assert output["options"]["maxScale"] == 500000 + +def test_read_existing_lwc_3_6_to_3_6(data: Path, qgis_iface: QgisInterface): + """Test to read a CFG 3.6 and to export it to 3.6 about scales.""" + # Checking CFG before opening the QGS file + project = QgsProject.instance() + project.read(str(data.joinpath("3857_project_lwc_3_6.qgs"))) + assert len(project.mapLayers()) == 1 + + lizmap = Lizmap(qgis_iface, lwc_version=LwcVersions.Lizmap_3_6) + # read_cfg_file will call "layers_config_file" + lizmap.read_cfg_file(skip_tables=True) + + assert lizmap.dlg.minimum_scale.text() == "1000" + assert lizmap.dlg.maximum_scale.text() == "500000" + assert lizmap.dlg.list_map_scales.text() == "1000, 5000, 10000, 500000" + + output = lizmap.project_config_file( + LwcVersions.Lizmap_3_6, + check_server=False, + ignore_error=True, + ) + + # Project is in EPSG:3857, must be False because of LWC 3.6 + assert not output["options"]["use_native_zoom_levels"] + + TestCase.assertListEqual([1000, 5000, 10000, 500000], output["options"]["mapScales"]) + assert output["options"]["minScale"] == 1000 + assert output["options"]["maxScale"] == 500000 + +def test_atlas_auto_play_true_values(data: Path, qgis_iface: QgisInterface): + """Test some UI settings about boolean values.""" + lizmap = _setup_empty_project(data, qgis_iface) + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + assert output["options"].get("atlasAutoPlay") is None + + lizmap.dlg.atlasAutoPlay.setChecked(True) + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + assert output["options"].get("atlasAutoPlay") + + lizmap.dlg.atlasAutoPlay.setChecked(False) + + output = lizmap.project_config_file( + LwcVersions.latest(), + check_server=False, + ignore_error=True, + ) + + assert output["options"].get("atlasAutoPlay") is None + +def test_geolocation_values(data: Path, qgis_iface: QgisInterface): + """Test geolocation UI settings.""" + lizmap = _setup_empty_project(data, qgis_iface) + + # Default geolocation checkboxes checked + assert not lizmap.dlg.groupbox_geolocation.isChecked() + assert lizmap.dlg.checkbox_geolocation_precision.isChecked() + assert not lizmap.dlg.checkbox_geolocation_direction.isChecked() + + # Default geolocation checkboxes enabled + assert lizmap.dlg.groupbox_geolocation.isEnabled() + assert not lizmap.dlg.checkbox_geolocation_precision.isEnabled() + assert not lizmap.dlg.checkbox_geolocation_direction.isEnabled() + + # Check geolocation checkbox to enable other checkboxes + lizmap.dlg.groupbox_geolocation.setChecked(True) + assert lizmap.dlg.checkbox_geolocation_precision.isEnabled() + assert lizmap.dlg.checkbox_geolocation_direction.isEnabled() + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # options + assert output["options"].get("geolocation") + assert output["options"].get("geolocationPrecision") + assert not output["options"].get("geolocationDirection") + + # Check direction + lizmap.dlg.checkbox_geolocation_direction.setChecked(True) + # Uncheck precision + lizmap.dlg.checkbox_geolocation_precision.setChecked(False) + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # options + assert output["options"].get("geolocation") + assert not output["options"].get("geolocationPrecision") + assert output["options"].get("geolocationDirection") + + +def test_exclude_basemaps_from_single_wms_values(data: Path, qgis_iface: QgisInterface): + """Test exclude basemaps from single WMS UI settings.""" + lizmap = _setup_empty_project(data, qgis_iface) + + # Default checkbox states + assert not lizmap.dlg.checkbox_wms_single_request_all_layers.isChecked() + assert not lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isChecked() + + # Exclude basemaps checkbox should be disabled when single WMS is off + assert not lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isEnabled() + + # Enable single WMS - exclude basemaps checkbox should become enabled + lizmap.dlg.checkbox_wms_single_request_all_layers.setChecked(True) + assert lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isEnabled() + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # Single WMS enabled, exclude basemaps still unchecked + assert output["options"].get("wms_single_request_for_all_layers") + assert not output["options"].get("exclude_basemaps_from_single_wms") + + # Enable exclude basemaps + lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.setChecked(True) + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # Both options enabled + assert output["options"].get("wms_single_request_for_all_layers") + assert output["options"].get("exclude_basemaps_from_single_wms") + + # Disable single WMS - exclude basemaps checkbox should become disabled + lizmap.dlg.checkbox_wms_single_request_all_layers.setChecked(False) + assert not lizmap.dlg.checkbox_exclude_basemaps_from_single_wms.isEnabled() + + +def test_group_popup_by_layer(data: Path, qgis_iface: QgisInterface): + """Test group popup by layer UI settings.""" + lizmap = _setup_empty_project(data, qgis_iface) + + # Default checkbox states + assert not lizmap.dlg.checkbox_group_popup_by_layer.isChecked() + + # Enable group popup by layer + lizmap.dlg.checkbox_group_popup_by_layer.setChecked(True) + assert lizmap.dlg.checkbox_group_popup_by_layer.isEnabled() + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # Group popup by layers option should be checked in output config file + assert output["options"].get("group_popup_by_layer") + + # Disable group popup by layer + lizmap.dlg.checkbox_group_popup_by_layer.setChecked(False) + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # Group popup by layers option should be unchecked in output config file + assert not output["options"].get("group_popup_by_layer") + + +def test_short_link_permalink(data: Path, qgis_iface: QgisInterface): + """Test short link permalink UI settings.""" + lizmap = _setup_empty_project(data, qgis_iface) + + # Default checkbox states + assert not lizmap.dlg.checkbox_short_link_permalink.isChecked() + + # Enable short link permalink + lizmap.dlg.checkbox_short_link_permalink.setChecked(True) + assert lizmap.dlg.checkbox_short_link_permalink.isEnabled() + + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # Short link permalink option should be checked in output config file + assert output["options"].get("short_link_permalink") + + # Disable short link permalink + lizmap.dlg.checkbox_short_link_permalink.setChecked(False) + output = lizmap.project_config_file(LwcVersions.latest(), check_server=False, ignore_error=True) + # Short link permalink option should be unchecked in output config file + assert not output["options"].get("short_link_permalink") From ed67f23b79e72129c8e16c307bf6a38338e7352b Mon Sep 17 00:00:00 2001 From: David Marteau Date: Mon, 1 Jun 2026 12:12:25 +0200 Subject: [PATCH 3/3] Fix rebasing from master --- lizmap/dialogs/main.py | 6 ++--- lizmap/dialogs/server_wizard.py | 6 +---- lizmap/drag_drop_dataviz_manager.py | 4 +-- lizmap/plugin/core.py | 38 +---------------------------- lizmap/plugin/layer_tree.py | 10 +++----- lizmap/server_dav.py | 6 +---- lizmap/server_lwc.py | 14 ++++------- lizmap/table_manager/base.py | 11 +++++---- lizmap/table_manager/dataviz.py | 9 ++----- lizmap/table_manager/layouts.py | 11 +++++---- 10 files changed, 28 insertions(+), 87 deletions(-) diff --git a/lizmap/dialogs/main.py b/lizmap/dialogs/main.py index 6d2406c9..e9ec2af0 100644 --- a/lizmap/dialogs/main.py +++ b/lizmap/dialogs/main.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from qgis.core import ( Qgis, @@ -25,6 +25,7 @@ QPushButton, QSizePolicy, QSpacerItem, + QWidget, ) from qgis.utils import OverrideCursor, iface @@ -92,9 +93,6 @@ WEB_ENGINE = False WEBKIT_AVAILABLE = False -if TYPE_CHECKING: - from qgis.PyQt.QtWidgets import QWidget - FORM_CLASS = load_ui('ui_lizmap.ui') diff --git a/lizmap/dialogs/server_wizard.py b/lizmap/dialogs/server_wizard.py index ae077cf5..f2b45bf9 100644 --- a/lizmap/dialogs/server_wizard.py +++ b/lizmap/dialogs/server_wizard.py @@ -5,7 +5,6 @@ from base64 import b64encode from enum import IntEnum, auto from functools import partial -from typing import TYPE_CHECKING from qgis.core import ( Qgis, @@ -39,6 +38,7 @@ QSpacerItem, QSpinBox, QVBoxLayout, + QWidget, QWizard, QWizardPage, ) @@ -54,10 +54,6 @@ from ..toolbelt.plugin import lizmap_user_folder, user_settings from ..toolbelt.version import version -if TYPE_CHECKING: - from qgis.PyQt.QtWidgets import QWidget - - THUMBS = " 👍" DEBUG = True diff --git a/lizmap/drag_drop_dataviz_manager.py b/lizmap/drag_drop_dataviz_manager.py index a647d0cc..d35b7bae 100644 --- a/lizmap/drag_drop_dataviz_manager.py +++ b/lizmap/drag_drop_dataviz_manager.py @@ -1,5 +1,4 @@ from enum import Enum, unique -from typing import TYPE_CHECKING from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtGui import QBrush, QIcon @@ -16,11 +15,10 @@ ) from . import logger +from .definitions.dataviz import DatavizDefinitions from .toolbelt.i18n import tr from .toolbelt.resources import resources_path -if TYPE_CHECKING: - from lizmap.definitions.dataviz import DatavizDefinitions @unique class Container(Enum): diff --git a/lizmap/plugin/core.py b/lizmap/plugin/core.py index 4bc40798..5cfa4572 100644 --- a/lizmap/plugin/core.py +++ b/lizmap/plugin/core.py @@ -3,13 +3,6 @@ from functools import partial from os.path import relpath -from pathlib import Path - -from typing import ( - TYPE_CHECKING, - Dict, - Optional, -) from qgis.core import ( Qgis, @@ -23,6 +16,7 @@ QgsSettings, QgsVectorLayer, ) +from qgis.gui import QgisInterface from qgis.PyQt.QtCore import ( QCoreApplication, Qt, @@ -141,11 +135,6 @@ from .training import TrainingManager from .webdav import WebDavManager -if TYPE_CHECKING: - from qgis.gui import QgisInterface - -from . import helpers - VERSION_URL = "https://raw.githubusercontent.com/3liz/lizmap-web-client/versions/versions.json" # To try a local file # VERSION_URL = 'file:///home/etienne/.local/share/QGIS/QGIS3/profiles/default/Lizmap/released_versions.json' @@ -531,31 +520,6 @@ def __init__(self, iface: QgisInterface, lwc_version: LwcVersions = None): self.help_action_cloud = None def configure_dev_version(self): - # File handler for logging - temp_dir = Path(tempfile.gettempdir()).joinpath("QGIS_Lizmap") - if not temp_dir.exists(): - temp_dir.mkdir() - - if not as_boolean(os.getenv("CI")): - file_handler = logging.FileHandler(temp_dir.joinpath("lizmap.log")) - file_handler.setLevel(logging.DEBUG) - formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - file_handler.setFormatter(formatter) - add_logging_handler_once(LOGGER, file_handler) - LOGGER.debug( - f"The directory {temp_dir} " - "is currently used for file logging." - ) - - # All logs - def write_log_message(message, tag, level): - """Write all tabs from QGIS to files.""" - temp_dir_log = Path(tempfile.gettempdir()).joinpath("QGIS_Lizmap") - with open(temp_dir_log.joinpath("all.log"), "a") as log_file: - log_file.write(f"{tag}({level}): {message}") - - QgsApplication.messageLog().messageReceived.connect(write_log_message) - self.dlg.setWindowTitle( f"Lizmap branch {self.version}, commit {current_git_hash()}, next {next_git_tag()}" ) diff --git a/lizmap/plugin/layer_tree.py b/lizmap/plugin/layer_tree.py index fa7b17d0..deda5387 100644 --- a/lizmap/plugin/layer_tree.py +++ b/lizmap/plugin/layer_tree.py @@ -5,8 +5,8 @@ import os from typing import ( - TYPE_CHECKING, Any, + Optional, Protocol, Tuple, ) @@ -25,6 +25,7 @@ QgsVectorLayer, QgsWkbTypes, ) +from qgis.gui import QgisInterface from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import ( @@ -33,6 +34,7 @@ ) from .. import logger +from ..config import GlobalOptionsDefinitions, LayerOptionDefinitions from ..definitions.definitions import ( DURATION_WARNING_BAR, GroupNames, @@ -55,12 +57,6 @@ ) from .helpers import display_error, string_to_list -if TYPE_CHECKING: - from qgis.gui import QgisInterface - - from ..config import GlobalOptionsDefinitions, LayerOptionDefinitions - from ..dialogs.main import LizmapDialog - class LizmapProtocol(Protocol): dlg: LizmapDialog diff --git a/lizmap/server_dav.py b/lizmap/server_dav.py index f2f3420b..4c4a5643 100644 --- a/lizmap/server_dav.py +++ b/lizmap/server_dav.py @@ -1,7 +1,6 @@ from base64 import b64encode from collections import namedtuple from pathlib import Path -from typing import TYPE_CHECKING from qgis.core import ( Qgis, @@ -17,14 +16,11 @@ from . import logger from .definitions.definitions import RepositoryComboData, ServerComboData +from .dialogs.main import LizmapDialog from .saas import webdav_properties from .toolbelt.i18n import tr from .toolbelt.strings import path_to_url -if TYPE_CHECKING: - from .dialogs.main import LizmapDialog - - PropFindFileResponse = namedtuple( 'PropFindFile', [ diff --git a/lizmap/server_lwc.py b/lizmap/server_lwc.py index 8d38bb19..ab5797b8 100644 --- a/lizmap/server_lwc.py +++ b/lizmap/server_lwc.py @@ -6,7 +6,6 @@ from functools import partial from pathlib import Path from typing import ( - TYPE_CHECKING, Callable, ) @@ -40,29 +39,26 @@ QWidget, ) -from lizmap.definitions.definitions import ( +from . import logger +from .definitions.definitions import ( DEV_VERSION_PREFIX, UNSTABLE_VERSION_PREFIX, ReleaseStatus, ServerComboData, ) -from lizmap.definitions.lizmap_cloud import CLOUD_QGIS_MIN_RECOMMENDED -from lizmap.dialogs.server_wizard import ( +from .definitions.lizmap_cloud import CLOUD_QGIS_MIN_RECOMMENDED +from .dialogs.main import LizmapDialog +from .dialogs.server_wizard import ( CreateFolderWizard, NamePage, ServerWizard, ) - -from . import logger from .saas import is_lizmap_cloud, webdav_properties from .toolbelt.convert import ambiguous_to_bool from .toolbelt.i18n import tr from .toolbelt.plugin import lizmap_user_folder, user_settings from .toolbelt.version import qgis_version_info, version -if TYPE_CHECKING: - from .dialogs.main import LizmapDialog - class TableCell(Enum): """ Cells in the table. """ diff --git a/lizmap/table_manager/base.py b/lizmap/table_manager/base.py index e03ee94a..56985759 100644 --- a/lizmap/table_manager/base.py +++ b/lizmap/table_manager/base.py @@ -5,7 +5,6 @@ import os from collections import namedtuple -from typing import TYPE_CHECKING from qgis.core import QgsMapLayerModel, QgsMasterLayoutInterface, QgsProject from qgis.PyQt.QtCore import Qt @@ -20,16 +19,18 @@ ) from .. import logger -from ..definitions.base import BaseDefinitions, InputType +from ..definitions.base import ( + BaseDefinitions, + InputType, + InputTypeError, +) from ..definitions.dataviz import AggregationType, GraphType from ..definitions.definitions import LwcVersions +from ..dialogs.main import LizmapDialog from ..qt_style_sheets import NEW_FEATURE_CSS from ..toolbelt.convert import as_boolean from ..toolbelt.i18n import tr -if TYPE_CHECKING: - from .dialogs.main import LizmapDialog - class CellError(Exception): pass diff --git a/lizmap/table_manager/dataviz.py b/lizmap/table_manager/dataviz.py index 2a0bc6c4..f6ed7801 100644 --- a/lizmap/table_manager/dataviz.py +++ b/lizmap/table_manager/dataviz.py @@ -2,8 +2,6 @@ import json -from typing import TYPE_CHECKING - from qgis.core import ( QgsApplication, QgsAuthMethodConfig, @@ -26,8 +24,10 @@ from qgis.utils import OverrideCursor from .. import logger +from ..definitions.base import BaseDefinitions from ..definitions.dataviz import GraphType from ..definitions.definitions import ServerComboData +from ..dialogs.main import LizmapDialog from ..dialogs.server_wizard import ServerWizard from ..table_manager.base import TableManager from ..toolbelt.convert import as_boolean @@ -35,11 +35,6 @@ from ..toolbelt.resources import resources_path from ..toolbelt.strings import merge_strings -if TYPE_CHECKING: - from lizmap.definitions.base import BaseDefinitions - from lizmap.dialogs.main import LizmapDialog - - class TableManagerDataviz(TableManager): diff --git a/lizmap/table_manager/layouts.py b/lizmap/table_manager/layouts.py index 2c8e9cf5..ef1fd05d 100644 --- a/lizmap/table_manager/layouts.py +++ b/lizmap/table_manager/layouts.py @@ -1,18 +1,19 @@ """ Table manager for layouts. """ from enum import Enum -from typing import TYPE_CHECKING from qgis.core import QgsMasterLayoutInterface, QgsProject from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import ( + QAbstractButton, + QDialog, + QWidget, +) from .. import logger +from ..definitions.base import BaseDefinitions from ..definitions.definitions import LwcVersions from .base import TableManager -if TYPE_CHECKING: - from qgis.PyQt.QtWidgets import QAbstractButton, QDialog, QWidget - from lizmap.definitions.base import BaseDefinitions - class TableManagerLayouts(TableManager):