From d78fe93bde0eed711d5ef1d37c97baa8a0626371 Mon Sep 17 00:00:00 2001 From: Etienne Trimaille Date: Wed, 14 Aug 2024 14:37:15 +0200 Subject: [PATCH] Add tests about layer with expression, simplify code --- dynamic_layers/core/dynamic_layers_engine.py | 54 ++---- dynamic_layers/core/generate_projects.py | 4 +- .../core/layer_datasource_modifier.py | 14 +- dynamic_layers/dynamic_layers.py | 6 +- dynamic_layers/dynamic_layers_dialog.py | 4 +- dynamic_layers/tools.py | 6 +- tests/test_core.py | 166 +++++++++++++----- 7 files changed, 155 insertions(+), 99 deletions(-) diff --git a/dynamic_layers/core/dynamic_layers_engine.py b/dynamic_layers/core/dynamic_layers_engine.py index 1562619..8934124 100644 --- a/dynamic_layers/core/dynamic_layers_engine.py +++ b/dynamic_layers/core/dynamic_layers_engine.py @@ -8,7 +8,6 @@ QgsExpression, QgsFeature, QgsFeatureRequest, - QgsMapLayer, QgsMessageLog, QgsProject, QgsRectangle, @@ -32,10 +31,10 @@ class DynamicLayersEngine: def __init__(self): """ Dynamic Layers Engine constructor. """ - self._extent_layer = None - self._extent_margin = None - self._dynamic_layers: dict = {} - self._search_and_replace_dictionary: dict = {} + self.extent_layer = None + self.extent_margin = None + self.dynamic_layers: dict = {} + self.variables: dict = {} self.iface = iface # For expressions @@ -43,31 +42,7 @@ def __init__(self): self.layer = None self.feature = None - @property - def extent_layer(self) -> QgsMapLayer: - return self._extent_layer - - @extent_layer.setter - def extent_layer(self, layer: QgsMapLayer): - self._extent_layer = layer - - @property - def extent_margin(self) -> int: - return self._extent_margin - - @extent_margin.setter - def extent_margin(self, extent: int): - self._extent_margin = extent - - @property - def search_and_replace_dictionary(self) -> dict: - return self._search_and_replace_dictionary - - @search_and_replace_dictionary.setter - def search_and_replace_dictionary(self, values: dict): - self._search_and_replace_dictionary = values - - def set_search_and_replace_dictionary_from_layer(self, layer: QgsVectorLayer, expression: str): + def set_layer_and_expression(self, layer: QgsVectorLayer, expression: str): """ Set the search and replace dictionary from a given layer and an expression. The first found feature is the data source @@ -85,36 +60,31 @@ def set_search_and_replace_dictionary_from_layer(self, layer: QgsVectorLayer, ex # Take only first feature feature = QgsFeature() features.nextFeature(feature) - self.set_search_and_replace_dictionary_from_feature(layer, feature) + self.set_layer_and_feature(layer, feature) - def set_search_and_replace_dictionary_from_feature(self, layer: QgsVectorLayer, feature: QgsFeature): + def set_layer_and_feature(self, layer: QgsVectorLayer, feature: QgsFeature): """ Set a feature for the dictionary. """ self.layer = layer self.feature = feature - self.search_and_replace_dictionary = dict(zip(layer.fields().names(), feature.attributes())) def discover_dynamic_layers_from_project(self, project: QgsProject): """ Check all maplayers in the given project which are dynamic. """ self.project = project - self._dynamic_layers = { + self.dynamic_layers = { lid: layer for lid, layer in project.mapLayers().items() if layer.customProperty(CustomProperty.DynamicDatasourceActive) and layer.customProperty( CustomProperty.DynamicDatasourceContent) } - def update_dynamic_layers_datasource_from_dict(self): + def update_dynamic_layers_datasource(self): """ For each layers with "active" status, Change the datasource by using the dynamicDatasourceContent And the given search&replace dictionary """ - if len(self.search_and_replace_dictionary) < 1: - return - - for lid, layer in self._dynamic_layers.items(): - # Change datasource + for layer in self.dynamic_layers.values(): a = LayerDataSourceModifier(layer, self.project, self.layer, self.feature) - a.set_new_source_uri_from_dict(self.search_and_replace_dictionary) + a.compute_new_uri(self.variables) if not self.iface: continue @@ -161,7 +131,7 @@ def set_project_property(self, project_property: Annotated[str, WmsProjectProper # Replace variable in given val via dictionary val = string_substitution( input_string=val, - variables=self.search_and_replace_dictionary, + variables=self.variables, project=self.project, layer=self.layer, feature=self.feature, diff --git a/dynamic_layers/core/generate_projects.py b/dynamic_layers/core/generate_projects.py index 8f6d510..0a2bf23 100644 --- a/dynamic_layers/core/generate_projects.py +++ b/dynamic_layers/core/generate_projects.py @@ -51,12 +51,12 @@ def process(self) -> bool: # noinspection PyUnresolvedReferences request.setFlags(QgsFeatureRequest.NoGeometry) for feature in self.coverage.getFeatures(request): - engine.set_search_and_replace_dictionary_from_feature(self.coverage, feature) + engine.set_layer_and_feature(self.coverage, feature) if self.feedback: self.feedback.pushDebugInfo(tr('Feature : {}').format(feature.id())) - engine.update_dynamic_layers_datasource_from_dict() + engine.update_dynamic_layers_datasource() engine.update_dynamic_project_properties() new_file = string_substitution( diff --git a/dynamic_layers/core/layer_datasource_modifier.py b/dynamic_layers/core/layer_datasource_modifier.py index 550710b..48df826 100644 --- a/dynamic_layers/core/layer_datasource_modifier.py +++ b/dynamic_layers/core/layer_datasource_modifier.py @@ -4,8 +4,13 @@ import typing -from qgis._core import QgsProject, QgsVectorLayer, QgsFeature -from qgis.core import QgsMapLayer, QgsReadWriteContext +from qgis.core import ( + QgsFeature, + QgsMapLayer, + QgsProject, + QgsReadWriteContext, + QgsVectorLayer, +) from qgis.PyQt.QtXml import QDomDocument from dynamic_layers.definitions import CustomProperty @@ -27,7 +32,7 @@ def __init__(self, layer: QgsMapLayer, project: QgsProject, layer_context: QgsVe # Content of the dynamic datasource self.dynamic_datasource_content = layer.customProperty(CustomProperty.DynamicDatasourceContent) - def set_new_source_uri_from_dict(self, search_and_replace_dictionary: dict = None): + def compute_new_uri(self, search_and_replace_dictionary: dict = None): """ Get the dynamic datasource template, Replace variable with passed data, @@ -45,6 +50,9 @@ def set_new_source_uri_from_dict(self, search_and_replace_dictionary: dict = Non feature=self.feature ) + if not new_uri: + raise Exception(f"New URI invalid : {new_uri}") + # Set the layer datasource self.set_data_source(new_uri) diff --git a/dynamic_layers/dynamic_layers.py b/dynamic_layers/dynamic_layers.py index 585f7b3..16a40b0 100644 --- a/dynamic_layers/dynamic_layers.py +++ b/dynamic_layers/dynamic_layers.py @@ -845,14 +845,14 @@ def on_apply_variables_clicked(self): # Set search and replace dictionary # Collect variables names and values if self.dlg.is_table_variable_based: - engine.search_and_replace_dictionary = self.dlg.variables() + engine.variables = self.dlg.variables() else: layer = self.dlg.inVariableSourceLayer.currentLayer() exp = self.dlg.inVariableSourceLayerExpression.text() - engine.set_search_and_replace_dictionary_from_layer(layer, exp) + engine.set_layer_and_expression(layer, exp) # Change layers datasource - engine.update_dynamic_layers_datasource_from_dict() + engine.update_dynamic_layers_datasource() # Set project properties engine.update_dynamic_project_properties() diff --git a/dynamic_layers/dynamic_layers_dialog.py b/dynamic_layers/dynamic_layers_dialog.py index fca5993..a7b8b36 100644 --- a/dynamic_layers/dynamic_layers_dialog.py +++ b/dynamic_layers/dynamic_layers_dialog.py @@ -6,10 +6,9 @@ from pathlib import Path from typing import Union -from qgis.PyQt.QtWidgets import QPlainTextEdit -from qgis.core import QgsExpression from qgis.core import ( QgsApplication, + QgsExpression, QgsExpressionContext, QgsExpressionContextScope, QgsExpressionContextUtils, @@ -21,6 +20,7 @@ QDialog, QDialogButtonBox, QLineEdit, + QPlainTextEdit, QTextEdit, QWidget, ) diff --git a/dynamic_layers/tools.py b/dynamic_layers/tools.py index 9e2b11a..86df5c4 100644 --- a/dynamic_layers/tools.py +++ b/dynamic_layers/tools.py @@ -48,13 +48,15 @@ def string_substitution( context.appendScope(scope) if is_template: - return QgsExpression.replaceExpressionText(input_string, context) + output = QgsExpression.replaceExpressionText(input_string, context) + return output expression = QgsExpression(input_string) if expression.hasEvalError() or expression.hasParserError(): raise QgsProcessingException(f"Invalid QGIS expression : {input_string}") - return expression.evaluate(context) + output = expression.evaluate(context) + return output def plugin_path(*args) -> Path: diff --git a/tests/test_core.py b/tests/test_core.py index d4b068b..5bc3d93 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,6 @@ __license__ = 'GPL version 3' __email__ = 'info@3liz.org' -import string import unittest from pathlib import Path @@ -23,12 +22,116 @@ class TestBasicReplacement(BaseTests): - def test_replacement_map_layer(self): - """ Test datasource can be replaced. """ + def _coverage_layer(self) -> QgsVectorLayer: + """ Internal function for the coverage layer. """ + coverage = QgsVectorLayer( + f"None?&" + f"field=id_feature:integer&" + f"field=folder:string(20)&" + f"field=name:string(20)&" + f"index=yes", "coverage", "memory") + with edit(coverage): + feature = QgsFeature(coverage.fields()) + feature.setAttributes([1, "folder_1", "Name 1"]) + # noinspection PyArgumentList + coverage.addFeature(feature) + + feature = QgsFeature(coverage.fields()) + feature.setAttributes([2, "folder_2", "Name 2"]) + # noinspection PyArgumentList + coverage.addFeature(feature) + + feature = QgsFeature(coverage.fields()) + feature.setAttributes([3, "folder_3", "Name 3"]) + # noinspection PyArgumentList + coverage.addFeature(feature) + + self.assertEqual(3, coverage.featureCount()) + unique_values = coverage.uniqueValues(coverage.fields().indexFromName("folder")) + self.assertSetEqual({'folder_1', 'folder_2', 'folder_3'}, unique_values) + return coverage + + def test_replacement_feature(self): + """ Test datasource can be replaced using a feature. """ # noinspection PyArgumentList project = QgsProject() + token = '"name"' + + # Empty short name + self.assertTupleEqual(('', False), project.readEntry(WmsProjectProperty.ShortName, "/")) + + # Set a short name template + project.writeEntry(PLUGIN_SCOPE, PluginProjectProperty.ShortName, f"concat('Shortname ', \"folder\")") + project.writeEntry(PLUGIN_SCOPE, PluginProjectProperty.Abstract, f"concat('Abstract ', \"folder\")") + project.writeEntry(PLUGIN_SCOPE, PluginProjectProperty.Title, f"concat('Title ', \"folder\")") + + vector = QgsVectorLayer(str(Path(f"fixtures/folder_1/lines_1.geojson")), f"Layer folder_1") + self.assertTrue(vector.isValid()) + project.addMapLayer(vector) + + engine = DynamicLayersEngine() + engine.discover_dynamic_layers_from_project(project) + self.assertDictEqual({}, engine.dynamic_layers) + + vector.setCustomProperty(CustomProperty.DynamicDatasourceActive, True) + dynamic_source = vector.source() + + self.assertIn('folder_1', dynamic_source) + self.assertNotIn('folder_2', dynamic_source) + self.assertNotIn(token, dynamic_source) + + # It must be a QGIS expression + dynamic_source = f"concat('fixtures/folder_', \"id_feature\", '/lines_', \"id_feature\", '.geojson')" + vector.setCustomProperty(CustomProperty.DynamicDatasourceContent, dynamic_source) + vector.setCustomProperty(CustomProperty.NameTemplate, f"concat('Custom layer name ', \"folder\")") + vector.setCustomProperty(CustomProperty.TitleTemplate, f"concat('Custom layer title ', \"folder\")") + vector.setCustomProperty(CustomProperty.AbstractTemplate, f"concat('Custom layer abstract ', \"folder\")") + + engine.discover_dynamic_layers_from_project(project) + self.assertDictEqual( + { + vector.id(): vector + }, + engine.dynamic_layers + ) + + # Replace + engine.set_layer_and_expression(self._coverage_layer(), "\"folder\" = 'folder_2'") - # We will use variables @, not fields "" + engine.update_dynamic_layers_datasource() + engine.update_dynamic_project_properties() + + self.assertIn('folder_2', vector.source()) + self.assertNotIn('folder_1', vector.source()) + self.assertNotIn(token, vector.source()) + self.assertTrue(vector.isValid()) + + # Layer properties + self.assertEqual(f"Custom layer name 2", vector.name()) + self.assertEqual(f"Custom layer title 2", vector.title()) + self.assertEqual(f"Custom layer abstract 2", vector.abstract()) + + # Project properties + # Short name + self.assertTupleEqual( + (f'Shortname folder_2', True), + project.readEntry(WmsProjectProperty.ShortName, "/") + ) + # Abstract + self.assertTupleEqual( + (f'Abstract folder_2', True), + project.readEntry(WmsProjectProperty.Abstract, "/") + ) + # WMS + self.assertTupleEqual( + ('1', True), + project.readEntry(WmsProjectProperty.Capabilities, "/") + ) + + def test_replacement_variables(self): + """ Test datasource can be replaced using variables. """ + # noinspection PyArgumentList + project = QgsProject() token = '@x' # Empty short name @@ -42,11 +145,10 @@ def test_replacement_map_layer(self): vector = QgsVectorLayer(str(Path(f"fixtures/folder_1/lines_1.geojson")), f"Layer folder_1") self.assertTrue(vector.isValid()) project.addMapLayer(vector) - self.assertEqual(1, len(project.mapLayers())) engine = DynamicLayersEngine() engine.discover_dynamic_layers_from_project(project) - self.assertDictEqual({}, engine._dynamic_layers) + self.assertDictEqual({}, engine.dynamic_layers) vector.setCustomProperty(CustomProperty.DynamicDatasourceActive, True) dynamic_source = vector.source() @@ -67,15 +169,15 @@ def test_replacement_map_layer(self): { vector.id(): vector }, - engine._dynamic_layers + engine.dynamic_layers ) # Replace - engine.search_and_replace_dictionary = { + engine.variables = { 'x': '2', } - engine.update_dynamic_layers_datasource_from_dict() + engine.update_dynamic_layers_datasource() engine.update_dynamic_project_properties() self.assertIn('folder_2', vector.source()) @@ -136,47 +238,19 @@ def test_generate_projects(self): self.assertTrue(project.write()) self.assertTrue(parent_project.exists()) - field = "folder" - name = "name" - coverage = QgsVectorLayer( - f"None?&" - f"field=id:integer&" - f"field={field}:string(20)&" - f"field={name}:string(20)&" - f"index=yes", "coverage", "memory") - with edit(coverage): - feature = QgsFeature(coverage.fields()) - feature.setAttributes([1, "folder_1", "Name 1"]) - # noinspection PyArgumentList - coverage.addFeature(feature) - - feature = QgsFeature(coverage.fields()) - feature.setAttributes([2, "folder_2", "Name 2"]) - # noinspection PyArgumentList - coverage.addFeature(feature) - - feature = QgsFeature(coverage.fields()) - feature.setAttributes([3, "folder_3", "Name 3"]) - # noinspection PyArgumentList - coverage.addFeature(feature) - - self.assertEqual(3, coverage.featureCount()) + coverage = self._coverage_layer() - field_name = coverage.fields().at(1).name() generator = GenerateProjects( - project, coverage, field_name, template_destination, Path(self.temp_dir), True) + project, coverage, "folder", template_destination, Path(self.temp_dir), True) self.assertTrue(generator.process()) - unique_values = coverage.uniqueValues(coverage.fields().indexFromName(field_name)) - self.assertSetEqual({'folder_1', 'folder_2', 'folder_3'}, unique_values) - for feature in coverage.getFeatures(): expected_project = string_substitution( input_string=template_destination, variables={ - 'folder': feature[field], - 'name': feature[name], + 'folder': feature['folder'], + 'name': feature['name'], }, project=project, layer=coverage, @@ -185,22 +259,24 @@ def test_generate_projects(self): expected_path = Path(self.temp_dir).joinpath(expected_project) self.assertTrue( expected_path.exists(), - f"In folder {self.temp_dir}, {expected_project} for value = {feature[field]} does not exist") + f"In folder {self.temp_dir}, {expected_project} for value = {feature['folder']} does not exist") # Test sidecar side = Path(str(expected_path) + ".png") self.assertTrue( side.exists(), - f"In folder {self.temp_dir}, {side} for value = {feature[field]} does not exist for the side car file") + f"In folder {self.temp_dir}, {side} for value = {feature['folder']} " + f"does not exist for the side car file" + ) child_project = QgsProject() child_project.read(str(expected_path)) layer = child_project.mapLayersByName(layer_name)[0] - self.assertTrue(feature[field] in layer.source()) + self.assertTrue(feature['folder'] in layer.source()) # Check short name self.assertTupleEqual( - (f'Abstract {feature[field]}', True), + (f'Abstract {feature["folder"]}', True), child_project.readEntry(WmsProjectProperty.Abstract, "/") )