Skip to content

Commit

Permalink
[feature] qgis_process now supports running a Python script algorithm
Browse files Browse the repository at this point in the history
directly by specifying the path to the .py file in place of
an algorithm ID or model file path
  • Loading branch information
nyalldawson committed Dec 15, 2021
1 parent dde113d commit d0718cb
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 6 deletions.
12 changes: 11 additions & 1 deletion python/plugins/processing/script/ScriptAlgorithmProvider.py
Expand Up @@ -49,7 +49,7 @@ class ScriptAlgorithmProvider(QgsProcessingProvider):
def __init__(self):
super().__init__()
self.algs = []
self.folder_algorithms = []
self.additional_algorithm_classes = []
self.actions = [CreateNewScriptAction(),
AddScriptFromTemplateAction(),
OpenScriptFromFileAction(),
Expand Down Expand Up @@ -100,6 +100,13 @@ def supportsNonFileBasedOutput(self):
# they'll get an error if they use them with incompatible outputs...
return True

def add_algorithm_class(self, algorithm_class):
"""
Adds an algorithm class to the provider
"""
self.additional_algorithm_classes.append(algorithm_class)
self.refreshAlgorithms()

def loadAlgorithms(self):
self.algs = []
folders = ScriptUtils.scriptsFolders()
Expand All @@ -122,5 +129,8 @@ def loadAlgorithms(self):
if alg is not None:
self.algs.append(alg)

for alg_class in self.additional_algorithm_classes:
self.algs.append(alg_class())

for a in self.algs:
self.addAlgorithm(a)
38 changes: 33 additions & 5 deletions src/process/qgsprocess.cpp
Expand Up @@ -460,7 +460,7 @@ void QgsProcessingExec::showUsage( const QString &appName )

msg << "QGIS Processing Executor - " << VERSION << " '" << RELEASE_NAME << "' ("
<< Qgis::version() << ")\n"
<< "Usage: " << appName << " [--help] [--version] [--json] [--verbose] [command] [algorithm id or path to model file] [parameters]\n"
<< "Usage: " << appName << " [--help] [--version] [--json] [--verbose] [command] [algorithm id, path to model file, or path to Python script] [parameters]\n"
<< "\nOptions:\n"
<< "\t--help or -h\t\tOutput the help\n"
<< "\t--version or -v\t\tOutput all versions related to QGIS Process\n"
Expand Down Expand Up @@ -679,8 +679,10 @@ int QgsProcessingExec::enablePlugin( const QString &name, bool enabled )
#endif
}

int QgsProcessingExec::showAlgorithmHelp( const QString &id, bool useJson )
int QgsProcessingExec::showAlgorithmHelp( const QString &inputId, bool useJson )
{
QString id = inputId;

std::unique_ptr< QgsProcessingModelAlgorithm > model;
const QgsProcessingAlgorithm *alg = nullptr;
if ( QFile::exists( id ) && QFileInfo( id ).suffix() == QLatin1String( "model3" ) )
Expand All @@ -694,7 +696,19 @@ int QgsProcessingExec::showAlgorithmHelp( const QString &id, bool useJson )

alg = model.get();
}
else
else if ( mPythonUtils && QFile::exists( id ) && QFileInfo( id ).suffix() == QLatin1String( "py" ) )
{
QString res;
if ( !mPythonUtils->evalString( QStringLiteral( "qgis.utils.import_script_algorithm(\"%1\")" ).arg( id ), res ) || res.isEmpty() )
{
std::cerr << QStringLiteral( "File %1 is not a valid Processing script!\n" ).arg( id ).toLocal8Bit().constData();
return 1;
}

id = res;
}

if ( !alg )
{
alg = QgsApplication::processingRegistry()->algorithmById( id );
if ( ! alg )
Expand Down Expand Up @@ -861,14 +875,16 @@ int QgsProcessingExec::showAlgorithmHelp( const QString &id, bool useJson )
return 0;
}

int QgsProcessingExec::execute( const QString &id, const QVariantMap &params, const QString &ellipsoid, QgsUnitTypes::DistanceUnit distanceUnit, QgsUnitTypes::AreaUnit areaUnit, QgsProcessingContext::LogLevel logLevel, bool useJson, const QString &projectPath )
int QgsProcessingExec::execute( const QString &inputId, const QVariantMap &params, const QString &ellipsoid, QgsUnitTypes::DistanceUnit distanceUnit, QgsUnitTypes::AreaUnit areaUnit, QgsProcessingContext::LogLevel logLevel, bool useJson, const QString &projectPath )
{
QVariantMap json;
if ( useJson )
{
addVersionInformation( json );
}

QString id = inputId;

std::unique_ptr< QgsProcessingModelAlgorithm > model;
const QgsProcessingAlgorithm *alg = nullptr;
if ( QFile::exists( id ) && QFileInfo( id ).suffix() == QLatin1String( "model3" ) )
Expand All @@ -882,7 +898,19 @@ int QgsProcessingExec::execute( const QString &id, const QVariantMap &params, co

alg = model.get();
}
else
else if ( mPythonUtils && QFile::exists( id ) && QFileInfo( id ).suffix() == QLatin1String( "py" ) )
{
QString res;
if ( !mPythonUtils->evalString( QStringLiteral( "qgis.utils.import_script_algorithm(\"%1\")" ).arg( id ), res ) || res.isEmpty() )
{
std::cerr << QStringLiteral( "File %1 is not a valid Processing script!\n" ).arg( id ).toLocal8Bit().constData();
return 1;
}

id = res;
}

if ( !alg )
{
alg = QgsApplication::processingRegistry()->algorithmById( id );
if ( ! alg )
Expand Down
54 changes: 54 additions & 0 deletions tests/src/python/test_qgsprocessexecutable.py
Expand Up @@ -286,6 +286,60 @@ def testModelRunWithLog(self):

self.assertIn('Test logged message', lines)

def testPythonScriptHelp(self):
rc, output, err = self.run_process(['help', TEST_DATA_DIR + '/convert_to_upper.py'])
if os.environ.get('TRAVIS', '') != 'true':
# Travis DOES have errors, due to QStandardPaths: XDG_RUNTIME_DIR not set warnings raised by Qt
self.assertFalse(err)
self.assertEqual(rc, 0)
self.assertIn('converts a string to upper case', output.lower())

def testPythonScriptRun(self):
rc, output, err = self.run_process(['run', TEST_DATA_DIR + '/convert_to_upper.py', '--', 'INPUT=abc'])
if os.environ.get('TRAVIS', '') != 'true':
# Travis DOES have errors, due to QStandardPaths: XDG_RUNTIME_DIR not set warnings raised by Qt
self.assertFalse(err)
self.assertEqual(rc, 0)
self.assertIn('Converted abc to ABC', output)
self.assertIn('OUTPUT:\tABC', output)

def testPythonScriptRunJson(self):
rc, output, err = self.run_process(['run', TEST_DATA_DIR + '/convert_to_upper.py', '--json', '--', 'INPUT=abc'])
if os.environ.get('TRAVIS', '') != 'true':
# Travis DOES have errors, due to QStandardPaths: XDG_RUNTIME_DIR not set warnings raised by Qt
self.assertFalse(err)
self.assertEqual(rc, 0)

res = json.loads(output)
self.assertIn('gdal_version', res)
self.assertIn('geos_version', res)
self.assertIn('proj_version', res)
self.assertIn('python_version', res)
self.assertIn('qt_version', res)
self.assertIn('qgis_version', res)
self.assertEqual(res['algorithm_details']['id'], 'script:converttouppercase')
self.assertEqual(res['results']['OUTPUT'], 'ABC')

def testPythonScriptRunNotAlgorithm(self):
rc, output, err = self.run_process(['run', TEST_DATA_DIR + '/not_a_processing_script.py'])
self.assertEqual(rc, 1)
self.assertIn('is not a valid Processing script', err)

def testPythonScriptHelpNotAlgorithm(self):
rc, output, err = self.run_process(['help', TEST_DATA_DIR + '/not_a_processing_script.py'])
self.assertEqual(rc, 1)
self.assertIn('is not a valid Processing script', err)

def testPythonScriptRunError(self):
rc, output, err = self.run_process(['run', TEST_DATA_DIR + '/script_with_error.py'])
self.assertEqual(rc, 1)
self.assertIn('is not a valid Processing script', err)

def testPythonScriptHelpError(self):
rc, output, err = self.run_process(['help', TEST_DATA_DIR + '/script_with_error.py'])
self.assertEqual(rc, 1)
self.assertIn('is not a valid Processing script', err)


if __name__ == '__main__':
# look for qgis bin path
Expand Down
58 changes: 58 additions & 0 deletions tests/testdata/convert_to_upper.py
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-

"""
***************************************************************************
* *
* 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. *
* *
***************************************************************************
"""

from qgis.PyQt.QtCore import QCoreApplication
from qgis.core import (QgsProcessing,
QgsFeatureSink,
QgsProcessingException,
QgsProcessingAlgorithm,
QgsProcessingParameterString,
QgsProcessingOutputString)


class ConvertStringToUppercase(QgsProcessingAlgorithm):
INPUT = 'INPUT'
OUTPUT = 'OUTPUT'

def createInstance(self):
return ConvertStringToUppercase()

def name(self):
return 'converttouppercase'

def displayName(self):
return 'Convert to upper'

def shortDescription(self):
return 'Converts a string to upper case'

def initAlgorithm(self, config=None):
self.addParameter(
QgsProcessingParameterString(
self.INPUT,
'Input string'
)
)

self.addOutput(
QgsProcessingOutputString(
self.OUTPUT,
'Output string'
)
)

def processAlgorithm(self, parameters, context, feedback):
input_string = self.parameterAsString(parameters, self.INPUT, context)
output_string = input_string.upper()
feedback.pushInfo('Converted {} to {}'.format(input_string, output_string))
return {self.OUTPUT: output_string}
4 changes: 4 additions & 0 deletions tests/testdata/not_a_processing_script.py
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-

print('a')

4 changes: 4 additions & 0 deletions tests/testdata/script_with_error.py
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-

if 1==:

0 comments on commit d0718cb

Please sign in to comment.