diff --git a/python/plugins/processing/gui/AlgorithmExecutor.py b/python/plugins/processing/gui/AlgorithmExecutor.py index b7a56166666..195d306bf4d 100644 --- a/python/plugins/processing/gui/AlgorithmExecutor.py +++ b/python/plugins/processing/gui/AlgorithmExecutor.py @@ -34,7 +34,12 @@ from qgis.core import (Qgis, QgsMessageLog, QgsProcessingException, QgsProcessingFeatureSourceDefinition, - QgsProcessingParameters) + QgsProcessingParameters, + QgsProject, + QgsFeatureRequest, + QgsFeature, + QgsExpression, + QgsWkbTypes) from processing.gui.Postprocessing import handleAlgorithmResults from processing.tools import dataobjects from qgis.utils import iface @@ -63,40 +68,168 @@ def execute(alg, parameters, context=None, feedback=None): return False, {} -def execute_in_place(alg, parameters, context=None, feedback=None): +def make_feature_compatible(new_features, input_layer): + """Try to make new features compatible with old feature by: + + - converting single to multi part + - dropping additional attributes + - adding back M/Z values + + + :param new_features: new features + :type new_features: list of QgsFeatures + :param input_layer: input layer + :type input_layer: QgsVectorLayer + :return: modified features + :rtype: list of QgsFeatures + """ + + result_features = [] + for new_f in new_features: + if (new_f.geometry().wkbType() != input_layer.wkbType() and + QgsWkbTypes.isMultiType(input_layer.wkbType()) and not + new_f.geometry().isMultipart()): + new_geom = new_f.geometry() + new_geom.convertToMultiType() + new_f.setGeometry(new_geom) + if len(new_f.fields()) > len(input_layer.fields()): + f = QgsFeature(input_layer.fields()) + f.setGeometry(new_f.geometry()) + f.setAttributes(new_f.attributes()[:len(input_layer.fields())]) + result_features.append(new_f) + return result_features + + +def execute_in_place_run(alg, parameters, context=None, feedback=None): + """Executes an algorithm modifying features in-place in the input layer. + + The input layer must be editable or an exception is raised. + + :param alg: algorithm to run + :type alg: QgsProcessingAlgorithm + :param parameters: parameters of the algorithm + :type parameters: dict + :param context: context, defaults to None + :param context: QgsProcessingContext, optional + :param feedback: feedback, defaults to None + :param feedback: QgsProcessingFeedback, optional + :raises QgsProcessingException: raised when the layer is not editable or the layer cannot be found in the current project + :return: a tuple with true if success and results + :rtype: tuple + """ if feedback is None: feedback = QgsProcessingFeedback() if context is None: context = dataobjects.createContext(feedback) - parameters['INPUT'] = QgsProcessingFeatureSourceDefinition(iface.activeLayer().id(), True) + # It would be nicer to get the layer from INPUT with + # alg.parameterAsVectorLayer(parameters, 'INPUT', context) + # but it does not work. + active_layer_id, ok = parameters['INPUT'].source.value(context.expressionContext()) + if ok: + active_layer = QgsProject.instance().mapLayer(active_layer_id) + if active_layer is None or not active_layer.isEditable(): + raise QgsProcessingException(tr("Layer is not editable or layer with id '%s' could not be found in the current project.") % active_layer_id) + else: + return False, {} parameters['OUTPUT'] = 'memory:' try: - results, ok = alg.run(parameters, context, feedback) + new_feature_ids = [] - layer = QgsProcessingUtils.mapLayerFromString(results['OUTPUT'], context) - iface.activeLayer().beginEditCommand('Edit features') - iface.activeLayer().deleteFeatures(iface.activeLayer().selectedFeatureIds()) - features = [] - for f in layer.getFeatures(): - features.append(f) - iface.activeLayer().addFeatures(features) - new_selection = [f.id() for f in features] - iface.activeLayer().endEditCommand() - #iface.activeLayer().selectByIds(new_selection) - iface.activeLayer().triggerRepaint() + active_layer.beginEditCommand(tr('In-place editing by %s') % alg.name()) + + req = QgsFeatureRequest(QgsExpression(r"$id < 0")) + req.setFlags(QgsFeatureRequest.NoGeometry) + req.setSubsetOfAttributes([]) + + # Checks whether the algorithm has a processFeature method + if hasattr(alg, 'processFeature'): # in-place feature editing + alg.prepare(parameters, context, feedback) + field_idxs = range(len(active_layer.fields())) + feature_iterator = active_layer.getFeatures(QgsFeatureRequest(active_layer.selectedFeatureIds())) if parameters['INPUT'].selectedFeaturesOnly else active_layer.getFeatures() + for f in feature_iterator: + new_features = alg.processFeature(f, context, feedback) + new_features = make_feature_compatible(new_features, active_layer) + if len(new_features) == 0: + active_layer.deleteFeature(f.id()) + elif len(new_features) == 1: + new_f = new_features[0] + if not f.geometry().equals(new_f.geometry()): + active_layer.changeGeometry(f.id(), new_f.geometry()) + if f.attributes() != new_f.attributes(): + active_layer.changeAttributeValues(f.id(), dict(zip(field_idxs, new_f.attributes())), dict(zip(field_idxs, f.attributes()))) + new_feature_ids.append(f.id()) + else: + active_layer.deleteFeature(f.id()) + # Get the new ids + old_ids = set([f.id() for f in active_layer.getFeatures(req)]) + active_layer.addFeatures(new_features) + new_ids = set([f.id() for f in active_layer.getFeatures(req)]) + new_feature_ids += list(new_ids - old_ids) + + results, ok = {}, True + + else: # Traditional 'run' with delete and add features cycle + results, ok = alg.run(parameters, context, feedback) + + result_layer = QgsProcessingUtils.mapLayerFromString(results['OUTPUT'], context) + # TODO: check if features have changed before delete/add cycle + active_layer.deleteFeatures(active_layer.selectedFeatureIds()) + new_features = [] + for f in result_layer.getFeatures(): + new_features.append(make_feature_compatible([f], active_layer)) + + # Get the new ids + old_ids = set([f.id() for f in active_layer.getFeatures(req)]) + active_layer.addFeatures(new_features) + new_ids = set([f.id() for f in active_layer.getFeatures(req)]) + new_feature_ids += list(new_ids - old_ids) + + if ok and new_feature_ids: + active_layer.selectByIds(new_feature_ids) + elif not ok: + active_layer.rollback() + + active_layer.endEditCommand() return ok, results + except QgsProcessingException as e: QgsMessageLog.logMessage(str(sys.exc_info()[0]), 'Processing', Qgis.Critical) if feedback is not None: feedback.reportError(e.msg) + return False, {} +def execute_in_place(alg, parameters, context=None, feedback=None): + """Executes an algorithm modifying features in-place in the active layer. + + The input layer must be editable or an exception is raised. + + :param alg: algorithm to run + :type alg: QgsProcessingAlgorithm + :param parameters: parameters of the algorithm + :type parameters: dict + :param context: context, defaults to None + :param context: QgsProcessingContext, optional + :param feedback: feedback, defaults to None + :param feedback: QgsProcessingFeedback, optional + :raises QgsProcessingException: raised when the layer is not editable or the layer cannot be found in the current project + :return: a tuple with true if success and results + :rtype: tuple + """ + + parameters['INPUT'] = QgsProcessingFeatureSourceDefinition(iface.activeLayer().id(), True) + ok, results = execute_in_place_run(alg, parameters, context=context, feedback=feedback) + if ok: + iface.activeLayer().triggerRepaint() + return ok, results + + def executeIterating(alg, parameters, paramToIter, context, feedback): # Generate all single-feature layers parameter_definition = alg.parameterDefinition(paramToIter) diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index ce1837fa460..982df23d76d 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -142,6 +142,7 @@ ADD_PYTHON_TEST(PyQgsPointClusterRenderer test_qgspointclusterrenderer.py) ADD_PYTHON_TEST(PyQgsPointDisplacementRenderer test_qgspointdisplacementrenderer.py) ADD_PYTHON_TEST(PyQgsPostgresDomain test_qgspostgresdomain.py) ADD_PYTHON_TEST(PyQgsProcessingRecentAlgorithmLog test_qgsprocessingrecentalgorithmslog.py) +ADD_PYTHON_TEST(PyQgsProcessingInPlace test_qgsprocessinginplace.py) ADD_PYTHON_TEST(PyQgsProjectionSelectionWidgets test_qgsprojectionselectionwidgets.py) ADD_PYTHON_TEST(PyQgsProjectMetadata test_qgsprojectmetadata.py) ADD_PYTHON_TEST(PyQgsRange test_qgsrange.py)