From 87d2da13fbc760f8511316b618ba482062b76544 Mon Sep 17 00:00:00 2001 From: Nathan Woodrow Date: Mon, 10 Dec 2018 16:35:52 +1000 Subject: [PATCH] [FEATURE][needs-docs] Add new @alg decorator for nicer python processing scripts. (#8586) @alg() @alg.help() @alg.input() @alg.output() --- python/CMakeLists.txt | 1 + .../processing/script/DeleteScriptAction.py | 2 +- .../processing/script/EditScriptAction.py | 4 +- .../processing/script/ScriptEditorDialog.py | 16 +- .../plugins/processing/script/ScriptUtils.py | 21 +- python/processing/CMakeLists.txt | 25 + python/processing/__init__.py | 28 + python/processing/algfactory.py | 502 ++++++++++++++++++ python/utils.py | 30 ++ src/app/qgisapp.cpp | 26 +- tests/src/python/CMakeLists.txt | 1 + .../python/test_processing_alg_decorator.py | 162 ++++++ 12 files changed, 778 insertions(+), 40 deletions(-) create mode 100644 python/processing/CMakeLists.txt create mode 100644 python/processing/__init__.py create mode 100644 python/processing/algfactory.py create mode 100644 tests/src/python/test_processing_alg_decorator.py diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 9be4f7cb7091..602f6c5e391d 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -66,6 +66,7 @@ ENDIF () ADD_SUBDIRECTORY(PyQt) ADD_SUBDIRECTORY(ext-libs) ADD_SUBDIRECTORY(testing) +ADD_SUBDIRECTORY(processing) INCLUDE_DIRECTORIES(SYSTEM ${PYTHON_INCLUDE_PATH} diff --git a/python/plugins/processing/script/DeleteScriptAction.py b/python/plugins/processing/script/DeleteScriptAction.py index 96a3fe4f75e2..6d730bcd1418 100644 --- a/python/plugins/processing/script/DeleteScriptAction.py +++ b/python/plugins/processing/script/DeleteScriptAction.py @@ -52,7 +52,7 @@ def execute(self): QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply == QMessageBox.Yes: - filePath = ScriptUtils.findAlgorithmSource(self.itemData.__class__.__name__) + filePath = ScriptUtils.findAlgorithmSource(self.itemData.name()) if filePath is not None: os.remove(filePath) QgsApplication.processingRegistry().providerById("script").refreshAlgorithms() diff --git a/python/plugins/processing/script/EditScriptAction.py b/python/plugins/processing/script/EditScriptAction.py index 593d2c02041a..031ecf4bacfa 100644 --- a/python/plugins/processing/script/EditScriptAction.py +++ b/python/plugins/processing/script/EditScriptAction.py @@ -27,7 +27,7 @@ import inspect -from qgis.core import QgsProcessingAlgorithm +from qgis.core import QgsProcessingAlgorithm, QgsMessageLog from qgis.utils import iface from qgis.PyQt.QtCore import QCoreApplication from qgis.PyQt.QtWidgets import QMessageBox @@ -47,7 +47,7 @@ def isEnabled(self): return isinstance(self.itemData, QgsProcessingAlgorithm) and self.itemData.provider().id() == "script" def execute(self): - filePath = ScriptUtils.findAlgorithmSource(self.itemData.__class__.__name__) + filePath = ScriptUtils.findAlgorithmSource(self.itemData.name()) if filePath is not None: dlg = ScriptEditorDialog(filePath, iface.mainWindow()) dlg.show() diff --git a/python/plugins/processing/script/ScriptEditorDialog.py b/python/plugins/processing/script/ScriptEditorDialog.py index 098a0d8572dd..4a0090745374 100644 --- a/python/plugins/processing/script/ScriptEditorDialog.py +++ b/python/plugins/processing/script/ScriptEditorDialog.py @@ -44,6 +44,7 @@ QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm) from qgis.utils import iface, OverrideCursor +from qgis.processing import alg as algfactory from processing.gui.AlgorithmDialog import AlgorithmDialog from processing.script import ScriptUtils @@ -222,9 +223,9 @@ def setHasChanged(self, hasChanged): self.update_dialog_title() def runAlgorithm(self): - d = {} + _locals = {} try: - exec(self.editor.text(), d) + exec(self.editor.text(), _locals) except Exception as e: error = QgsError(traceback.format_exc(), "Processing") QgsErrorDialog.show(error, @@ -233,10 +234,13 @@ def runAlgorithm(self): return alg = None - for k, v in d.items(): - if inspect.isclass(v) and issubclass(v, (QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm)) and v.__name__ not in ("QgsProcessingAlgorithm", "QgsProcessingFeatureBasedAlgorithm"): - alg = v() - break + try: + alg = algfactory.instances.pop().createInstance() + except IndexError: + for name, attr in _locals.items(): + if inspect.isclass(attr) and issubclass(attr, (QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm)) and attr.__name__ not in ("QgsProcessingAlgorithm", "QgsProcessingFeatureBasedAlgorithm"): + alg = attr() + break if alg is None: QMessageBox.warning(self, diff --git a/python/plugins/processing/script/ScriptUtils.py b/python/plugins/processing/script/ScriptUtils.py index 4f85f6addb5b..f4c61b8b9f24 100644 --- a/python/plugins/processing/script/ScriptUtils.py +++ b/python/plugins/processing/script/ScriptUtils.py @@ -25,6 +25,7 @@ __revision__ = '$Format:%H$' +from qgis.processing import alg as algfactory import os import inspect import importlib @@ -66,20 +67,26 @@ def loadAlgorithm(moduleName, filePath): spec = importlib.util.spec_from_file_location(moduleName, filePath) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - for x in dir(module): - obj = getattr(module, x) - if inspect.isclass(obj) and issubclass(obj, (QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm)) and obj.__name__ not in ("QgsProcessingAlgorithm", "QgsProcessingFeatureBasedAlgorithm"): - scriptsRegistry[x] = filePath - return obj() + try: + alg = algfactory.instances.pop().createInstance() + scriptsRegistry[alg.name()] = filePath + return alg + except IndexError: + for x in dir(module): + obj = getattr(module, x) + if inspect.isclass(obj) and issubclass(obj, (QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm)) and obj.__name__ not in ("QgsProcessingAlgorithm", "QgsProcessingFeatureBasedAlgorithm"): + o = obj() + scriptsRegistry[o.name()] = filePath + return o except ImportError as e: QgsMessageLog.logMessage(QCoreApplication.translate("ScriptUtils", "Could not import script algorithm '{}' from '{}'\n{}").format(moduleName, filePath, str(e)), QCoreApplication.translate("ScriptUtils", "Processing"), Qgis.Critical) -def findAlgorithmSource(className): +def findAlgorithmSource(name): global scriptsRegistry try: - return scriptsRegistry[className] + return scriptsRegistry[name] except: return None diff --git a/python/processing/CMakeLists.txt b/python/processing/CMakeLists.txt new file mode 100644 index 000000000000..d28cd4310c8f --- /dev/null +++ b/python/processing/CMakeLists.txt @@ -0,0 +1,25 @@ +# See ../CMakeLists.txt for info on staged-plugins* and clean-staged-plugins targets + +SET(QGIS_PYTHON_DIR ${PYTHON_SITE_PACKAGES_DIR}/qgis) +SET (PYTHON_OUTPUT_DIRECTORY ${QGIS_OUTPUT_DIRECTORY}/python) +SET (NAME processing) + +SET(PY_FILES + __init__.py + algfactory.py +) + +FILE (MAKE_DIRECTORY ${QGIS_PYTHON_OUTPUT_DIRECTORY}/${NAME}) +INSTALL(FILES ${PY_FILES} DESTINATION "${QGIS_PYTHON_DIR}/${NAME}") + +ADD_CUSTOM_TARGET(py${NAME} ALL) +# stage to output to make available when QGIS is run from build directory +FOREACH(pyfile ${PY_FILES}) + ADD_CUSTOM_COMMAND(TARGET py${NAME} + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy ${pyfile} "${QGIS_PYTHON_OUTPUT_DIRECTORY}/${NAME}" + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + DEPENDS ${pyfile} + ) + PY_COMPILE(pyutils "${QGIS_PYTHON_OUTPUT_DIRECTORY}/${NAME}/${pyfile}") +ENDFOREACH(pyfile) diff --git a/python/processing/__init__.py b/python/processing/__init__.py new file mode 100644 index 000000000000..04c074788e83 --- /dev/null +++ b/python/processing/__init__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +""" +*************************************************************************** + __init__.py + --------------------- + Date : November 2018 + Copyright : (C) 2018 by Nathan Woodrow + Email : woodrow dot nathan at gmail dot com +*************************************************************************** +* * +* This program is free software; you can redistribute it and/or modify * +* it under the terms of the GNU General Public License as published by * +* the Free Software Foundation; either version 2 of the License, or * +* (at your option) any later version. * +* * +*************************************************************************** +""" + +__author__ = 'Nathan Woodrow' +__date__ = 'November 2018' +__copyright__ = '(C) 2018, Nathan Woodrow' +# This will get replaced with a git SHA1 when you do a git archive +__revision__ = '$Format:%H$' + +from .algfactory import ProcessingAlgFactory + +alg = ProcessingAlgFactory() diff --git a/python/processing/algfactory.py b/python/processing/algfactory.py new file mode 100644 index 000000000000..b774934bd1d5 --- /dev/null +++ b/python/processing/algfactory.py @@ -0,0 +1,502 @@ +# -*- coding: utf-8 -*- + +""" +*************************************************************************** + algfactory.py + --------------------- + Date : November 2018 + Copyright : (C) 2018 by Nathan Woodrow + Email : woodrow dot nathan at gmail dot com +*************************************************************************** +* * +* This program is free software; you can redistribute it and/or modify * +* it under the terms of the GNU General Public License as published by * +* the Free Software Foundation; either version 2 of the License, or * +* (at your option) any later version. * +* * +*************************************************************************** +""" + +__author__ = 'Nathan Woodrow' +__date__ = 'November 2018' +__copyright__ = '(C) 2018, Nathan Woodrow' +# This will get replaced with a git SHA1 when you do a git archive +__revision__ = '$Format:%H$' + +from collections import OrderedDict +from functools import partial + +from qgis.PyQt.QtCore import QCoreApplication +from qgis.PyQt.QtGui import QIcon +from qgis.core import (QgsProcessingParameterDefinition, + QgsProcessingAlgorithm, + QgsProcessingParameterString, + QgsProcessingParameterNumber, + QgsProcessingParameterDistance, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterFileDestination, + QgsProcessingParameterFolderDestination, + QgsProcessingParameterRasterDestination, + QgsProcessingParameterVectorDestination, + QgsProcessingParameterBand, + QgsProcessingParameterBoolean, + QgsProcessingParameterCrs, + QgsProcessingParameterEnum, + QgsProcessingParameterExpression, + QgsProcessingParameterExtent, + QgsProcessingParameterField, + QgsProcessingParameterFile, + QgsProcessingParameterMapLayer, + QgsProcessingParameterMatrix, + QgsProcessingParameterMultipleLayers, + QgsProcessingParameterPoint, + QgsProcessingParameterRange, + QgsProcessingParameterVectorLayer, + QgsProcessingOutputString, + QgsProcessingOutputFile, + QgsProcessingOutputFolder, + QgsProcessingOutputHtml, + QgsProcessingOutputLayerDefinition, + QgsProcessingOutputMapLayer, + QgsProcessingOutputMultipleLayers, + QgsProcessingOutputNumber, + QgsProcessingOutputRasterLayer, + QgsProcessingOutputVectorLayer, + QgsMessageLog) + + +def _log(*args, **kw): + """ + Log messages to the QgsMessageLog viewer + """ + QgsMessageLog.logMessage(" ".join(map(str, args)), "Factory") + + +def _make_output(**args): + """ + Create a processing output class type. + :param args: 'cls' The class object type. + 'name' the name of the output + 'description' The description used on the output + :return: + """ + cls = args['cls'] + del args['cls'] + newargs = { + "name": args['name'], + "description": args['description'], + } + return cls(**newargs) + + +class ProcessingAlgFactoryException(Exception): + """ + Exception raised when using @alg on a function. + """ + + def __init__(self, message): + super(ProcessingAlgFactoryException, self).__init__(message) + + +class AlgWrapper(QgsProcessingAlgorithm): + """ + Wrapper object used to create new processing algorithms from @alg. + """ + + def __init__(self, name=None, display=None, + group=None, group_id=None, inputs=None, + outputs=None, func=None, help=None, icon=None): + super(AlgWrapper, self).__init__() + self._inputs = OrderedDict(inputs or {}) + self._outputs = OrderedDict(outputs or {}) + self._icon = icon + self._name = name + self._group = group + self._group_id = group_id + self._display = display + self._func = func + self._help = help + + def _get_parent_id(self, parent): + """ + Return the id of the parent object. + """ + if isinstance(parent, str): + return parent + else: + raise NotImplementedError() + + # Wrapper logic + def define(self, name, label, group, group_label, help=None, icon=None): + self._name = name + self._display = label + self._group = group_label + self._group_id = group + self._help = help + self._icon = icon + + def end(self): + """ + Finalize the wrapper logic and check for any invalid config. + """ + if not self.has_outputs: + raise ProcessingAlgFactoryException("No outputs defined for '{}' alg" + "At least one must be defined. Use @alg.output") + + def add_output(self, type, **kwargs): + parm = self._create_param(type, output=True, **kwargs) + self._outputs[parm.name()] = parm + + def add_help(self, helpstring, *args, **kwargs): + self._help = helpstring + + def add_input(self, type, **kwargs): + parm = self._create_param(type, **kwargs) + self._inputs[parm.name()] = parm + + @property + def inputs(self): + return self._inputs + + @property + def outputs(self): + return self._outputs + + def _create_param(self, type, output=False, **kwargs): + name = kwargs['name'] + if name in self._inputs or name in self._outputs: + raise ProcessingAlgFactoryException("{} already defined".format(name)) + + parent = kwargs.get("parent") + if parent: + parentname = self._get_parent_id(parent) + if parentname == name: + raise ProcessingAlgFactoryException("{} can't depend on itself. " + "We know QGIS is smart but it's not that smart".format(name)) + if parentname not in self._inputs or parentname not in self._outputs: + raise ProcessingAlgFactoryException("Can't find parent named {}".format(parentname)) + + kwargs['description'] = kwargs.pop("label", "") + kwargs['defaultValue'] = kwargs.pop("default", "") + advanced = kwargs.pop("advanced", False) + try: + if output: + try: + make_func = output_type_mapping[type] + except KeyError: + raise ProcessingAlgFactoryException("{} is a invalid output type".format(type)) + else: + try: + make_func = input_type_mapping[type] + except KeyError: + raise ProcessingAlgFactoryException("{} is a invalid input type".format(type)) + parm = make_func(**kwargs) + if advanced: + parm.setFlags(parm.flags() | QgsProcessingParameterDefinition.FlagAdvanced) + return parm + except KeyError as ex: + raise NotImplementedError("{} not supported".format(str(type))) + + def set_func(self, func): + self._func = func + # Only take the help from the function if it hasn't already been set. + if self._func and not self._help: + self._help = self._func.__doc__.strip() + + @property + def has_outputs(self): + """ + True if this alg wrapper has outputs defined. + """ + return bool(self._outputs) + + @property + def has_inputs(self): + """ + True if this alg wrapper has outputs defined. + """ + return bool(self._inputs) + + def _get_parameter_value(self, parm, parameters, name, context): + """ + Extract the real value from the parameter. + """ + if isinstance(parm, QgsProcessingParameterString): + value = self.parameterAsString(parameters, name, context) + return value + elif isinstance(parm, QgsProcessingParameterNumber): + if parm.dataType() == QgsProcessingParameterNumber.Integer: + value = self.parameterAsInt(parameters, name, context) + return value + if parm.dataType() == QgsProcessingParameterNumber.Double: + value = self.parameterAsDouble(parameters, name, context) + return value + + # Overloads + def name(self): + return self._name + + def displayName(self): + return self._display + + def group(self): + return self._group + + def groupId(self): + return self._group_id + + def processAlgorithm(self, parameters, context, feedback): + values = {} + for parm in self._inputs.values(): + name = parm.name() + values[name] = self._get_parameter_value(parm, parameters, name, context) + + output = self._func(self, parameters, context, feedback, values) + if output is None: + return {} + return output + + def createInstance(self): + return AlgWrapper(self._name, self._display, + self._group, self._group_id, + inputs=self._inputs, + outputs=self._outputs, + func=self._func, + help=self._help, + icon=self._icon) + + def initAlgorithm(self, configuration=None, p_str=None, Any=None, *args, **kwargs): + for parm in self._inputs.values(): + self.addParameter(parm.clone()) + + for parm in self._outputs.values(): + clsname = parm.__class__.__name__ + klass = globals()[clsname] + clone = klass(parm.name(), parm.description()) + self.addOutput(clone) + + def shortHelpString(self): + return self._help + + def icon(self): + return QIcon(self._icon) + + +class ProcessingAlgFactory(): + STRING = "STRING", + INT = "INT", + NUMBER = "NUMBER", + DISTANCE = "DISTANCE", + SINK = "SINK" + SOURCE = "SOURCE" + FILE = "FILE", + FOLDER = "FOLDER", + HTML = "HTML", + LAYERDEF = "LAYERDEF", + MAPLAYER = "MAPLAYER", + MULTILAYER = "MULTILAYER", + RASTER_LAYER = "RASTER_LAYER", + VECTOR_LAYER = "VECTOR_LAYER", + FILE_DEST = "FILE_DEST", + FOLDER_DEST = "FOLDER_DEST", + RASTER_LAYER_DEST = "RASTER_LAYER_DEST", + VECTOR_LAYER_DEST = "VECTOR_LAYER_DEST", + BAND = "BAND", + BOOL = "BOOL", + CRS = "CRS", + ENUM = "ENUM", + EXPRESSION = "EXPRESSION", + EXTENT = "EXTENT", + FIELD = "FIELD", + MATRIX = "MATRIX", + POINT = "POINT", + RANGE = "RANGE", + + def __init__(self): + self._current = None + self.instances = [] + + def tr(self, string): + """ + Returns a translatable string with the self.tr() function. + """ + return QCoreApplication.translate('Processing', string) + + @property + def current(self): + return self._current + + @property + def current_defined(self): + return self._current is not None + + def __call__(self, *args, **kwargs): + return self._define(*args, **kwargs) + + def _initnew(self): + self._current = AlgWrapper() + + def _pop(self): + self.instances.append(self.current) + self._current = None + + def _define(self, *args, **kwargs): + self._initnew() + self.current.define(*args, **kwargs) + + def dec(f): + self.current.set_func(f) + self.current.end() + self._pop() + return f + + return dec + + def output(self, type, *args, **kwargs): + """ + Define a output parameter for this algorithm using @alg.output. + Apart from `type` this method will take all arguments and pass them though to the correct `QgsProcessingOutputDefinition ` type. + + Types: + str: QgsProcessingOutputString + int: QgsProcessingOutputNumber + float: QgsProcessingOutputNumber + alg.NUMBER: QgsProcessingOutputNumber + alg.DISTANCE: QgsProcessingOutputNumber + alg.INT: QgsProcessingOutputNumber + alg.STRING: QgsProcessingOutputString + alg.FILE: QgsProcessingOutputFile + alg.FOLDER: QgsProcessingOutputFolder + alg.HTML: QgsProcessingOutputHtml + alg.LAYERDEF: QgsProcessingOutputLayerDefinition + alg.MAPLAYER: QgsProcessingOutputMapLayer + alg.MULTILAYER: QgsProcessingOutputMultipleLayers + alg.RASTER_LAYER: QgsProcessingOutputRasterLayer + alg.VECTOR_LAYER: QgsProcessingOutputVectorLayer + + :param type: The type of the input. This should be a type define on `alg` like alg.STRING, alg.DISTANCE + :keyword label: The label of the output. Will convert into `description` arg. + :keyword parent: The string ID of the parent parameter. Parent parameter must be defined before its here. + """ + + def dec(f): + return f + + self.current.add_output(type, *args, **kwargs) + return dec + + def help(self, helpstring, *args, **kwargs): + """ + Define the help for the algorithm using @alg.help. This method will + be used instead of the doc string on the function as the help in the processing dialogs. + + :param helpstring: The help text. Use alg.tr() for translation support. + """ + + def dec(f): + return f + + self.current.add_help(helpstring, *args, **kwargs) + + return dec + + def input(self, type, *args, **kwargs): + """ + Define a input parameter for this algorithm using @alg.input. + Apart from `type` this method will take all arguments and pass them though to the correct `QgsProcessingParameterDefinition` type. + + Types: + + str: QgsProcessingParameterString + int: QgsProcessingParameterNumber + float: QgsProcessingParameterNumber + bool: QgsProcessingParameterBoolean + alg.NUMBER: QgsProcessingParameterNumber + alg.INT: QgsProcessingParameterNumber + alg.STRING: QgsProcessingParameterString + alg.DISTANCE: QgsProcessingParameterDistance + alg.SINK: QgsProcessingParameterFeatureSink + alg.SOURCE: QgsProcessingParameterFeatureSource + alg.FILE_DEST: QgsProcessingParameterFileDestination + alg.FOLDER_DEST: QgsProcessingParameterFolderDestination + alg.RASTER_LAYER_DEST: QgsProcessingParameterRasterDestination + alg.VECTOR_LAYER_DEST: QgsProcessingParameterVectorDestination + alg.BAND: QgsProcessingParameterBand + alg.BOOL: QgsProcessingParameterBoolean + alg.CRS: QgsProcessingParameterCrs + alg.ENUM: QgsProcessingParameterEnum + alg.EXPRESSION: QgsProcessingParameterExpression + alg.EXTENT: QgsProcessingParameterExtent + alg.FIELD: QgsProcessingParameterField + alg.FILE: QgsProcessingParameterFile + alg.MAPLAYER: QgsProcessingParameterMapLayer + alg.MATRIX: QgsProcessingParameterMatrix + alg.MULTILAYER: QgsProcessingParameterMultipleLayers + alg.POINT: QgsProcessingParameterPoint + alg.RANGE: QgsProcessingParameterRange + alg.VECTOR_LAYER: QgsProcessingParameterVectorLayer + + + :param type: The type of the input. This should be a type define on `alg` like alg.STRING, alg.DISTANCE + :keyword label: The label of the output. Translates into `description` arg. + :keyword parent: The string ID of the parent parameter. Parent parameter must be defined before its here. + :keyword default: The default value set for that parameter. Translates into `defaultValue` arg. + """ + + def dec(f): + return f + + self.current.add_input(type, *args, **kwargs) + + return dec + + +input_type_mapping = { + str: QgsProcessingParameterString, + int: partial(QgsProcessingParameterNumber, type=QgsProcessingParameterNumber.Integer), + float: partial(QgsProcessingParameterNumber, type=QgsProcessingParameterNumber.Double), + bool: QgsProcessingParameterBoolean, + ProcessingAlgFactory.NUMBER: partial(QgsProcessingParameterNumber, type=QgsProcessingParameterNumber.Double), + ProcessingAlgFactory.INT: partial(QgsProcessingParameterNumber, type=QgsProcessingParameterNumber.Integer), + ProcessingAlgFactory.STRING: QgsProcessingParameterString, + ProcessingAlgFactory.DISTANCE: QgsProcessingParameterDistance, + ProcessingAlgFactory.SINK: QgsProcessingParameterFeatureSink, + ProcessingAlgFactory.SOURCE: QgsProcessingParameterFeatureSource, + ProcessingAlgFactory.FILE_DEST: QgsProcessingParameterFileDestination, + ProcessingAlgFactory.FOLDER_DEST: QgsProcessingParameterFolderDestination, + ProcessingAlgFactory.RASTER_LAYER_DEST: QgsProcessingParameterRasterDestination, + ProcessingAlgFactory.VECTOR_LAYER_DEST: QgsProcessingParameterVectorDestination, + ProcessingAlgFactory.BAND: QgsProcessingParameterBand, + ProcessingAlgFactory.BOOL: QgsProcessingParameterBoolean, + ProcessingAlgFactory.CRS: QgsProcessingParameterCrs, + ProcessingAlgFactory.ENUM: QgsProcessingParameterEnum, + ProcessingAlgFactory.EXPRESSION: QgsProcessingParameterExpression, + ProcessingAlgFactory.EXTENT: QgsProcessingParameterExtent, + ProcessingAlgFactory.FIELD: QgsProcessingParameterField, + ProcessingAlgFactory.FILE: QgsProcessingParameterFile, + ProcessingAlgFactory.MAPLAYER: QgsProcessingParameterMapLayer, + ProcessingAlgFactory.MATRIX: QgsProcessingParameterMatrix, + ProcessingAlgFactory.MULTILAYER: QgsProcessingParameterMultipleLayers, + ProcessingAlgFactory.POINT: QgsProcessingParameterPoint, + ProcessingAlgFactory.RANGE: QgsProcessingParameterRange, + ProcessingAlgFactory.VECTOR_LAYER: QgsProcessingParameterVectorLayer, +} + +output_type_mapping = { + str: partial(_make_output, cls=QgsProcessingOutputString), + int: partial(_make_output, cls=QgsProcessingOutputNumber), + float: partial(_make_output, cls=QgsProcessingOutputNumber), + ProcessingAlgFactory.NUMBER: partial(_make_output, cls=QgsProcessingOutputNumber), + ProcessingAlgFactory.DISTANCE: partial(_make_output, cls=QgsProcessingOutputNumber), + ProcessingAlgFactory.INT: partial(_make_output, cls=QgsProcessingOutputNumber), + ProcessingAlgFactory.STRING: partial(_make_output, cls=QgsProcessingOutputString), + ProcessingAlgFactory.FILE: partial(_make_output, cls=QgsProcessingOutputFile), + ProcessingAlgFactory.FOLDER: partial(_make_output, cls=QgsProcessingOutputFolder), + ProcessingAlgFactory.HTML: partial(_make_output, cls=QgsProcessingOutputHtml), + ProcessingAlgFactory.LAYERDEF: partial(_make_output, cls=QgsProcessingOutputLayerDefinition), + ProcessingAlgFactory.MAPLAYER: partial(_make_output, cls=QgsProcessingOutputMapLayer), + ProcessingAlgFactory.MULTILAYER: partial(_make_output, cls=QgsProcessingOutputMultipleLayers), + ProcessingAlgFactory.RASTER_LAYER: partial(_make_output, cls=QgsProcessingOutputRasterLayer), + ProcessingAlgFactory.VECTOR_LAYER: partial(_make_output, cls=QgsProcessingOutputVectorLayer), +} diff --git a/python/utils.py b/python/utils.py index befc449a38e5..cd959ad07554 100644 --- a/python/utils.py +++ b/python/utils.py @@ -694,3 +694,33 @@ def _import(name, globals={}, locals={}, fromlist=[], level=None): builtins.__import__ = _import else: __builtin__.__import__ = _import + + +def run_script_from_file(filepath): + """ + Runs a Python script from a given file. Supports loading processing scripts. + :param filepath: The .py file to load. + """ + import sys + import inspect + from qgis.processing import alg + try: + from qgis.core import QgsApplication, QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm + from processing.gui.AlgorithmDialog import AlgorithmDialog + _locals = {} + exec(open(filepath.replace("\\\\", "/").encode(sys.getfilesystemencoding())).read(), _locals) + alginstance = None + try: + alginstance = alg.instances.pop().createInstance() + except IndexError: + for name, attr in _locals.items(): + if inspect.isclass(attr) and issubclass(attr, (QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm)) and attr.__name__ not in ("QgsProcessingAlgorithm", "QgsProcessingFeatureBasedAlgorithm"): + alginstance = attr() + break + if alginstance: + alginstance.setProvider(QgsApplication.processingRegistry().providerById("script")) + alginstance.initAlgorithm() + dlg = AlgorithmDialog(alginstance) + dlg.show() + except ImportError: + pass diff --git a/src/app/qgisapp.cpp b/src/app/qgisapp.cpp index 7d5a9cdd812d..c613af8b086a 100644 --- a/src/app/qgisapp.cpp +++ b/src/app/qgisapp.cpp @@ -6259,30 +6259,8 @@ void QgisApp::runScript( const QString &filePath ) if ( !showScriptWarning || msgbox.result() == QMessageBox::Yes ) { - mPythonUtils->runString( - QString( "import sys\n" - "import inspect\n" - "from qgis.utils import iface\n" - "try:\n" - " from qgis.core import QgsApplication, QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm\n" - " from processing.gui.AlgorithmDialog import AlgorithmDialog\n" - "except ImportError:\n" - " processing_found = False\n" - "else:\n" - " processing_found = True\n" - "d={}\n" - "exec(open(\"%1\".replace(\"\\\\\", \"/\").encode(sys.getfilesystemencoding())).read(), d)\n" - "if processing_found:\n" - " alg = None\n" - " for k, v in d.items():\n" - " if inspect.isclass(v) and issubclass(v, (QgsProcessingAlgorithm, QgsProcessingFeatureBasedAlgorithm)) and v.__name__ not in (\"QgsProcessingAlgorithm\", \"QgsProcessingFeatureBasedAlgorithm\"):\n" - " alg = v()\n" - " break\n" - " if alg:\n" - " alg.setProvider(QgsApplication.processingRegistry().providerById(\"script\"))\n" - " alg.initAlgorithm()\n" - " dlg = AlgorithmDialog(alg)\n" - " dlg.show()\n" ).arg( filePath ), tr( "Failed to run Python script:" ), false ); + mPythonUtils->runString( QString( "qgis.utils.run_script_from_file(\"%1\")" ).arg( filePath ), + tr( "Failed to run Python script:" ), false ); } #else Q_UNUSED( filePath ); diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index d7d0d28ce9a4..b0bd61f9ae9c 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -152,6 +152,7 @@ ADD_PYTHON_TEST(PyQgsPointDisplacementRenderer test_qgspointdisplacementrenderer ADD_PYTHON_TEST(PyQgsPostgresDomain test_qgspostgresdomain.py) ADD_PYTHON_TEST(PyQgsProcessingRecentAlgorithmLog test_qgsprocessingrecentalgorithmslog.py) ADD_PYTHON_TEST(PyQgsProcessingInPlace test_qgsprocessinginplace.py) +ADD_PYTHON_TEST(PyQgsProcessingAlgDecorator test_processing_alg_decorator.py) ADD_PYTHON_TEST(PyQgsProjectionSelectionWidgets test_qgsprojectionselectionwidgets.py) ADD_PYTHON_TEST(PyQgsProjectMetadata test_qgsprojectmetadata.py) ADD_PYTHON_TEST(PyQgsRange test_qgsrange.py) diff --git a/tests/src/python/test_processing_alg_decorator.py b/tests/src/python/test_processing_alg_decorator.py new file mode 100644 index 000000000000..50d2c2a097fe --- /dev/null +++ b/tests/src/python/test_processing_alg_decorator.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +"""QGIS Unit tests for the @alg processing algorithm. + +.. note:: This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. +""" +__author__ = 'Nathan Woodrow' +__date__ = '10.12.2018' +__copyright__ = 'Copyright 2018, The QGIS Project' +# This will get replaced with a git SHA1 when you do a git archive +__revision__ = '$Format:%H$' + +import sys +import os +import qgis # NOQA + +from qgis.testing import unittest, start_app +from qgis.processing import alg +from qgis.core import QgsSettings +from qgis.PyQt.QtCore import QCoreApplication + +start_app() + +ARGNAME = "TEST_ALG{0}" +HELPSTRING = "TEST_HELP STRING{0}" + + +def define_new_no_inputs(newid=1): + @alg(name="noinputs", label=alg.tr("Test func"), group="unittest", + group_label=alg.tr("Test label")) + @alg.output(type=str, name="DISTANCE_OUT", label="Distance out") + def testalg(instance, parameters, context, feedback, inputs): + """ + Test doc string text + """ + + +def define_new_doc_string(newid=1): + @alg(name=ARGNAME.format(newid), label=alg.tr("Test func"), group="unittest", + group_label=alg.tr("Test label")) + @alg.input(type=alg.SOURCE, name="INPUT", label="Input layer") + @alg.output(type=str, name="DISTANCE_OUT", label="Distance out") + def testalg(instance, parameters, context, feedback, inputs): + """ + Test doc string text + """ + + +def define_new(newid=1): + @alg(name=ARGNAME.format(newid), label=alg.tr("Test func"), group="unittest", + group_label=alg.tr("Test label")) + @alg.help(HELPSTRING.format(newid)) + @alg.input(type=alg.SOURCE, name="INPUT", label="Input layer") + @alg.input(type=alg.DISTANCE, name="DISTANCE", label="Distance", default=30) + @alg.input(type=alg.SINK, name="SINK", label="Output layer") + @alg.output(type=str, name="DISTANCE_OUT", label="Distance out") + def testalg(instance, parameters, context, feedback, inputs): + """ + Given a distance will split a line layer into segments of the distance + """ + + +def cleanup(): + alg.instances.clear() + + +class AlgNoInputs(unittest.TestCase): + def setUp(self): + cleanup() + + def test_can_have_no_inputs(self): + define_new_no_inputs() + + +class AlgInstanceTests(unittest.TestCase): + """ + Tests to check the createInstance method will work as expected. + """ + + def setUp(self): + cleanup() + define_new() + self.current = alg.instances.pop().createInstance() + + def test_correct_number_of_inputs_and_outputs(self): + self.assertEqual(3, len(self.current.inputs)) + self.assertEqual(1, len(self.current.outputs)) + + def test_correct_number_of_inputs_and_outputs_after_init(self): + self.current.initAlgorithm() + defs = self.current.parameterDefinitions() + self.assertEqual(3, len(defs)) + inputs = [ + ("INPUT", "Input layer"), + ("DISTANCE", "Distance"), + ("SINK", "Output layer"), + ] + for count, data in enumerate(inputs): + parmdef = defs[count] + self.assertEqual(data[0], parmdef.name()) + self.assertEqual(data[1], parmdef.description()) + + def test_func_is_set(self): + self.assertIsNotNone(self.current._func) + + def test_has_help_from_help_decorator(self): + self.assertEqual(HELPSTRING.format(1), self.current.shortHelpString()) + + def test_name_and_label(self): + self.assertEqual(ARGNAME.format(1), self.current.name()) + self.assertEqual("Test func", self.current.displayName()) + + def test_group(self): + self.assertEqual("Test label", self.current.group()) + self.assertEqual("unittest", self.current.groupId()) + + +class AlgHelpTests(unittest.TestCase): + def test_has_help_from_help_decorator(self): + cleanup() + define_new() + current = alg.instances.pop() + self.assertEqual(HELPSTRING.format(1), current.shortHelpString()) + + def test_has_help_from_docstring(self): + define_new_doc_string() + current = alg.instances.pop() + self.assertEqual("Test doc string text", current.shortHelpString()) + + +class TestAlg(unittest.TestCase): + def setUp(self): + cleanup() + define_new() + + def test_correct_number_of_inputs_and_outputs(self): + current = alg.instances.pop() + self.assertEqual(3, len(current.inputs)) + self.assertEqual(1, len(current.outputs)) + self.assertTrue(current.has_inputs) + self.assertTrue(current.has_outputs) + + def test_correct_number_defined_in_stack_before_and_after(self): + self.assertEqual(1, len(alg.instances)) + alg.instances.pop() + self.assertEqual(0, len(alg.instances)) + + def test_current_has_correct_name(self): + alg.instances.pop() + for i in range(3): + define_new(i) + + self.assertEqual(3, len(alg.instances)) + for i in range(3, 1): + current = alg.instances.pop() + self.assertEqual(ARGNAME.format(i), current.name()) + + +if __name__ == "__main__": + unittest.main()