diff --git a/python/plugins/otbprovider/CMakeLists.txt b/python/plugins/otbprovider/CMakeLists.txt index 2122113fa47a..facadb6e69e9 100644 --- a/python/plugins/otbprovider/CMakeLists.txt +++ b/python/plugins/otbprovider/CMakeLists.txt @@ -1,4 +1,6 @@ file(GLOB PY_FILES *.py) file(GLOB OTHER_FILES metadata.txt) +add_subdirectory(tests) + PLUGIN_INSTALL(otbprovider . ${PY_FILES} ${OTHER_FILES}) diff --git a/python/plugins/otbprovider/OtbChoiceWidget.py b/python/plugins/otbprovider/OtbChoiceWidget.py index 8d4a88908312..06872550cdae 100644 --- a/python/plugins/otbprovider/OtbChoiceWidget.py +++ b/python/plugins/otbprovider/OtbChoiceWidget.py @@ -152,7 +152,7 @@ def __init__(self, name='', description='', options=[], default=None, isSource=F self.setMetadata({ 'widget_wrapper': { - 'class': 'processing.algs.otb.OtbChoiceWidget.OtbChoiceWidgetWrapper'}}) + 'class': 'otbprovider.OtbChoiceWidget.OtbChoiceWidgetWrapper'}}) self.options = options if default is not None: diff --git a/python/plugins/otbprovider/tests/AlgorithmsTestBase.py b/python/plugins/otbprovider/tests/AlgorithmsTestBase.py new file mode 100644 index 000000000000..e862e3e31901 --- /dev/null +++ b/python/plugins/otbprovider/tests/AlgorithmsTestBase.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- + +""" +*************************************************************************** + AlgorithmsTest.py + --------------------- + Date : January 2016 + Copyright : (C) 2016 by Matthias Kuhn + Email : matthias@opengis.ch +*************************************************************************** +* * +* 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__ = 'Matthias Kuhn' +__date__ = 'January 2016' +__copyright__ = '(C) 2016, Matthias Kuhn' + +import qgis # NOQA switch sip api + +import os +import yaml +import nose2 +import shutil +import glob +import hashlib +import tempfile +import re + +from osgeo import gdal +from osgeo.gdalconst import GA_ReadOnly +from numpy import nan_to_num +from copy import deepcopy + +from qgis.core import (QgsVectorLayer, + QgsRasterLayer, + QgsCoordinateReferenceSystem, + QgsFeatureRequest, + QgsMapLayer, + QgsProject, + QgsApplication, + QgsProcessingContext, + QgsProcessingUtils, + QgsProcessingFeedback) +from qgis.analysis import (QgsNativeAlgorithms) +from qgis.testing import (_UnexpectedSuccess, + start_app, + unittest) +from utilities import unitTestDataPath + +import processing + + +def processingTestDataPath(): + return os.path.join(os.path.dirname(__file__), 'testdata') + + +class AlgorithmsTest(object): + + def test_algorithms(self): + """ + This is the main test function. All others will be executed based on the definitions in testdata/algorithm_tests.yaml + """ + with open(os.path.join(processingTestDataPath(), self.test_definition_file()), 'r') as stream: + algorithm_tests = yaml.load(stream, Loader=yaml.SafeLoader) + + if 'tests' in algorithm_tests and algorithm_tests['tests'] is not None: + for idx, algtest in enumerate(algorithm_tests['tests']): + print('About to start {} of {}: "{}"'.format(idx, len(algorithm_tests['tests']), algtest['name'])) + yield self.check_algorithm, algtest['name'], algtest + + def check_algorithm(self, name, defs): + """ + Will run an algorithm definition and check if it generates the expected result + :param name: The identifier name used in the test output heading + :param defs: A python dict containing a test algorithm definition + """ + self.vector_layer_params = {} + QgsProject.instance().clear() + + if 'project' in defs: + full_project_path = os.path.join(processingTestDataPath(), defs['project']) + project_read_success = QgsProject.instance().read(full_project_path) + self.assertTrue(project_read_success, 'Failed to load project file: ' + defs['project']) + + if 'project_crs' in defs: + QgsProject.instance().setCrs(QgsCoordinateReferenceSystem(defs['project_crs'])) + else: + QgsProject.instance().setCrs(QgsCoordinateReferenceSystem()) + + if 'ellipsoid' in defs: + QgsProject.instance().setEllipsoid(defs['ellipsoid']) + else: + QgsProject.instance().setEllipsoid('') + + params = self.load_params(defs['params']) + + print('Running alg: "{}"'.format(defs['algorithm'])) + alg = QgsApplication.processingRegistry().createAlgorithmById(defs['algorithm']) + + parameters = {} + if isinstance(params, list): + for param in zip(alg.parameterDefinitions(), params): + parameters[param[0].name()] = param[1] + else: + for k, p in params.items(): + parameters[k] = p + + for r, p in list(defs['results'].items()): + if 'in_place_result' not in p or not p['in_place_result']: + parameters[r] = self.load_result_param(p) + + expectFailure = False + if 'expectedFailure' in defs: + exec(('\n'.join(defs['expectedFailure'][:-1])), globals(), locals()) + expectFailure = eval(defs['expectedFailure'][-1]) + + if 'expectedException' in defs: + expectFailure = True + + # ignore user setting for invalid geometry handling + context = QgsProcessingContext() + context.setProject(QgsProject.instance()) + + if 'skipInvalid' in defs and defs['skipInvalid']: + context.setInvalidGeometryCheck(QgsFeatureRequest.GeometrySkipInvalid) + + feedback = QgsProcessingFeedback() + + print('Algorithm parameters are {}'.format(parameters)) + + # first check that algorithm accepts the parameters we pass... + ok, msg = alg.checkParameterValues(parameters, context) + self.assertTrue(ok, 'Algorithm failed checkParameterValues with result {}'.format(msg)) + + if expectFailure: + try: + results, ok = alg.run(parameters, context, feedback) + self.check_results(results, context, parameters, defs['results']) + if ok: + raise _UnexpectedSuccess + except Exception: + pass + else: + results, ok = alg.run(parameters, context, feedback) + self.assertTrue(ok, 'params: {}, results: {}'.format(parameters, results)) + self.check_results(results, context, parameters, defs['results']) + + def load_params(self, params): + """ + Loads an array of parameters + """ + if isinstance(params, list): + return [self.load_param(p) for p in params] + elif isinstance(params, dict): + return {key: self.load_param(p, key) for key, p in params.items()} + else: + return params + + def load_param(self, param, id=None): + """ + Loads a parameter. If it's not a map, the parameter will be returned as-is. If it is a map, it will process the + parameter based on its key `type` and return the appropriate parameter to pass to the algorithm. + """ + try: + if param['type'] in ('vector', 'raster', 'table'): + return self.load_layer(id, param).id() + elif param['type'] == 'vrtlayers': + vals = [] + for p in param['params']: + p['layer'] = self.load_layer(None, {'type': 'vector', 'name': p['layer']}) + vals.append(p) + return vals + elif param['type'] == 'multi': + return [self.load_param(p) for p in param['params']] + elif param['type'] == 'file': + return self.filepath_from_param(param) + elif param['type'] == 'interpolation': + prefix = processingTestDataPath() + tmp = '' + for r in param['name'].split('::|::'): + v = r.split('::~::') + tmp += '{}::~::{}::~::{}::~::{};'.format(os.path.join(prefix, v[0]), + v[1], v[2], v[3]) + return tmp[:-1] + except TypeError: + # No type specified, use whatever is there + return param + + raise KeyError("Unknown type '{}' specified for parameter".format(param['type'])) + + def load_result_param(self, param): + """ + Loads a result parameter. Creates a temporary destination where the result should go to and returns this location + so it can be sent to the algorithm as parameter. + """ + if param['type'] in ['vector', 'file', 'table', 'regex']: + outdir = tempfile.mkdtemp() + self.cleanup_paths.append(outdir) + if isinstance(param['name'], str): + basename = os.path.basename(param['name']) + else: + basename = os.path.basename(param['name'][0]) + + filepath = self.uri_path_join(outdir, basename) + return filepath + elif param['type'] == 'rasterhash': + outdir = tempfile.mkdtemp() + self.cleanup_paths.append(outdir) + if self.test_definition_file().lower().startswith('saga'): + basename = 'raster.sdat' + else: + basename = 'raster.tif' + filepath = os.path.join(outdir, basename) + return filepath + elif param['type'] == 'directory': + outdir = tempfile.mkdtemp() + return outdir + + raise KeyError("Unknown type '{}' specified for parameter".format(param['type'])) + + def load_layers(self, id, param): + layers = [] + if param['type'] in ('vector', 'table'): + if isinstance(param['name'], str) or 'uri' in param: + layers.append(self.load_layer(id, param)) + else: + for n in param['name']: + layer_param = deepcopy(param) + layer_param['name'] = n + layers.append(self.load_layer(id, layer_param)) + else: + layers.append(self.load_layer(id, param)) + return layers + + def load_layer(self, id, param): + """ + Loads a layer which was specified as parameter. + """ + + filepath = self.filepath_from_param(param) + + if 'in_place' in param and param['in_place']: + # check if alg modifies layer in place + tmpdir = tempfile.mkdtemp() + self.cleanup_paths.append(tmpdir) + path, file_name = os.path.split(filepath) + base, ext = os.path.splitext(file_name) + for file in glob.glob(os.path.join(path, '{}.*'.format(base))): + shutil.copy(os.path.join(path, file), tmpdir) + filepath = os.path.join(tmpdir, file_name) + self.in_place_layers[id] = filepath + + if param['type'] in ('vector', 'table'): + gmlrex = r'\.gml\b' + if re.search(gmlrex, filepath, re.IGNORECASE): + # ewwwww - we have to force SRS detection for GML files, otherwise they'll be loaded + # with no srs + filepath += '|option:FORCE_SRS_DETECTION=YES' + + if filepath in self.vector_layer_params: + return self.vector_layer_params[filepath] + + options = QgsVectorLayer.LayerOptions() + options.loadDefaultStyle = False + lyr = QgsVectorLayer(filepath, param['name'], 'ogr', options) + self.vector_layer_params[filepath] = lyr + elif param['type'] == 'raster': + options = QgsRasterLayer.LayerOptions() + options.loadDefaultStyle = False + lyr = QgsRasterLayer(filepath, param['name'], 'gdal', options) + + self.assertTrue(lyr.isValid(), 'Could not load layer "{}" from param {}'.format(filepath, param)) + QgsProject.instance().addMapLayer(lyr) + return lyr + + def filepath_from_param(self, param): + """ + Creates a filepath from a param + """ + prefix = processingTestDataPath() + if 'location' in param and param['location'] == 'qgs': + prefix = unitTestDataPath() + + if 'uri' in param: + path = param['uri'] + else: + path = param['name'] + + return self.uri_path_join(prefix, path) + + def uri_path_join(self, prefix, filepath): + if filepath.startswith('ogr:'): + if not prefix[-1] == os.path.sep: + prefix += os.path.sep + filepath = re.sub(r"dbname='", "dbname='{}".format(prefix), filepath) + else: + filepath = os.path.join(prefix, filepath) + + return filepath + + def check_results(self, results, context, params, expected): + """ + Checks if result produced by an algorithm matches with the expected specification. + """ + for id, expected_result in expected.items(): + if expected_result['type'] in ('vector', 'table'): + if 'compare' in expected_result and not expected_result['compare']: + # skipping the comparison, so just make sure output is valid + if isinstance(results[id], QgsMapLayer): + result_lyr = results[id] + else: + result_lyr = QgsProcessingUtils.mapLayerFromString(results[id], context) + self.assertTrue(result_lyr.isValid()) + continue + + expected_lyrs = self.load_layers(id, expected_result) + if 'in_place_result' in expected_result: + result_lyr = QgsProcessingUtils.mapLayerFromString(self.in_place_layers[id], context) + self.assertTrue(result_lyr.isValid(), self.in_place_layers[id]) + else: + try: + results[id] + except KeyError as e: + raise KeyError('Expected result {} does not exist in {}'.format(str(e), list(results.keys()))) + + if isinstance(results[id], QgsMapLayer): + result_lyr = results[id] + else: + string = results[id] + + gmlrex = r'\.gml\b' + if re.search(gmlrex, string, re.IGNORECASE): + # ewwwww - we have to force SRS detection for GML files, otherwise they'll be loaded + # with no srs + string += '|option:FORCE_SRS_DETECTION=YES' + + result_lyr = QgsProcessingUtils.mapLayerFromString(string, context) + self.assertTrue(result_lyr, results[id]) + + compare = expected_result.get('compare', {}) + pk = expected_result.get('pk', None) + + if len(expected_lyrs) == 1: + self.assertLayersEqual(expected_lyrs[0], result_lyr, compare=compare, pk=pk) + else: + res = False + for l in expected_lyrs: + if self.checkLayersEqual(l, result_lyr, compare=compare, pk=pk): + res = True + break + self.assertTrue(res, 'Could not find matching layer in expected results') + + elif 'rasterhash' == expected_result['type']: + print("id:{} result:{}".format(id, results[id])) + self.assertTrue(os.path.exists(results[id]), 'File does not exist: {}, {}'.format(results[id], params)) + dataset = gdal.Open(results[id], GA_ReadOnly) + dataArray = nan_to_num(dataset.ReadAsArray(0)) + strhash = hashlib.sha224(dataArray.data).hexdigest() + + if not isinstance(expected_result['hash'], str): + self.assertIn(strhash, expected_result['hash']) + else: + self.assertEqual(strhash, expected_result['hash']) + elif 'file' == expected_result['type']: + result_filepath = results[id] + if isinstance(expected_result.get('name'), list): + # test to see if any match expected + for path in expected_result['name']: + expected_filepath = self.filepath_from_param({'name': path}) + if self.checkFilesEqual(expected_filepath, result_filepath): + break + else: + expected_filepath = self.filepath_from_param({'name': expected_result['name'][0]}) + else: + expected_filepath = self.filepath_from_param(expected_result) + + self.assertFilesEqual(expected_filepath, result_filepath) + elif 'directory' == expected_result['type']: + expected_dirpath = self.filepath_from_param(expected_result) + result_dirpath = results[id] + + self.assertDirectoriesEqual(expected_dirpath, result_dirpath) + elif 'regex' == expected_result['type']: + with open(results[id], 'r') as file: + data = file.read() + + for rule in expected_result.get('rules', []): + self.assertRegex(data, rule) + + +class GenericAlgorithmsTest(unittest.TestCase): + """ + General (non-provider specific) algorithm tests + """ + + @classmethod + def setUpClass(cls): + start_app() + from processing.core.Processing import Processing + Processing.initialize() + cls.cleanup_paths = [] + + @classmethod + def tearDownClass(cls): + from processing.core.Processing import Processing + Processing.deinitialize() + for path in cls.cleanup_paths: + shutil.rmtree(path) + + def testAlgorithmCompliance(self): + for p in QgsApplication.processingRegistry().providers(): + print('testing provider {}'.format(p.id())) + for a in p.algorithms(): + print('testing algorithm {}'.format(a.id())) + self.check_algorithm(a) + + def check_algorithm(self, alg): + # check that calling helpUrl() works without error + alg.helpUrl() + + +if __name__ == '__main__': + nose2.main() diff --git a/python/plugins/otbprovider/tests/CMakeLists.txt b/python/plugins/otbprovider/tests/CMakeLists.txt new file mode 100644 index 000000000000..8a67dd5c3fb8 --- /dev/null +++ b/python/plugins/otbprovider/tests/CMakeLists.txt @@ -0,0 +1,8 @@ +file(GLOB PY_FILES *.py) + +PLUGIN_INSTALL(otbprovider tests ${PY_FILES}) + +if(ENABLE_TESTS) + include(UsePythonTest) + ADD_PYTHON_TEST(ProcessingOtbAlgorithmsTest OtbAlgorithmsTest.py) +endif() diff --git a/python/plugins/processing/tests/OtbAlgorithmsTest.py b/python/plugins/otbprovider/tests/OtbAlgorithmsTest.py similarity index 96% rename from python/plugins/processing/tests/OtbAlgorithmsTest.py rename to python/plugins/otbprovider/tests/OtbAlgorithmsTest.py index ceb8a38b265d..317418d31e2a 100644 --- a/python/plugins/processing/tests/OtbAlgorithmsTest.py +++ b/python/plugins/otbprovider/tests/OtbAlgorithmsTest.py @@ -45,10 +45,10 @@ from processing.gui.BatchAlgorithmDialog import BatchAlgorithmDialog from processing.gui.wrappers import WidgetWrapperFactory from processing.modeler.ModelerParametersDialog import ModelerParametersDialog -from processing.algs.otb.OtbAlgorithm import OtbAlgorithm -from processing.algs.otb.OtbAlgorithmProvider import OtbAlgorithmProvider -from processing.algs.otb.OtbUtils import OtbUtils -from processing.algs.otb.OtbChoiceWidget import OtbParameterChoice, OtbChoiceWidgetWrapper +from otbprovider.OtbAlgorithm import OtbAlgorithm +from otbprovider.OtbAlgorithmProvider import OtbAlgorithmProvider +from otbprovider.OtbUtils import OtbUtils +from otbprovider.OtbChoiceWidget import OtbParameterChoice, OtbChoiceWidgetWrapper import AlgorithmsTestBase import processing @@ -226,7 +226,8 @@ def setUpClass(cls): start_app() from processing.core.Processing import Processing Processing.initialize() - ProcessingConfig.setSettingValue("OTB_ACTIVATE", True) + cls.provider = OtbAlgorithmProvider() + QgsApplication.processingRegistry().addProvider(cls.provider) ProcessingConfig.setSettingValue(OtbUtils.FOLDER, OTB_INSTALL_DIR) ProcessingConfig.setSettingValue(OtbUtils.APP_FOLDER, os.path.join(OTB_INSTALL_DIR, 'lib', 'otb', 'applications')) ProcessingConfig.readSettings() @@ -241,6 +242,7 @@ def setUpClass(cls): def tearDownClass(cls): from processing.core.Processing import Processing Processing.deinitialize() + QgsApplication.processingRegistry().removeProvider(cls.provider) for path in cls.cleanup_paths: shutil.rmtree(path) diff --git a/python/plugins/processing/tests/testdata/otb_algorithm_tests.yaml b/python/plugins/otbprovider/tests/testdata/otb_algorithm_tests.yaml similarity index 100% rename from python/plugins/processing/tests/testdata/otb_algorithm_tests.yaml rename to python/plugins/otbprovider/tests/testdata/otb_algorithm_tests.yaml diff --git a/python/plugins/otbprovider/tests/testdata/polys.gfs b/python/plugins/otbprovider/tests/testdata/polys.gfs new file mode 100644 index 000000000000..8b7950032e25 --- /dev/null +++ b/python/plugins/otbprovider/tests/testdata/polys.gfs @@ -0,0 +1,31 @@ + + + polys2 + polys2 + 3 + EPSG:4326 + + 6 + -1.00000 + 10.00000 + -3.00000 + 6.00000 + + + name + name + String + 5 + + + intval + intval + Integer + + + floatval + floatval + Real + + + diff --git a/python/plugins/otbprovider/tests/testdata/polys.gml b/python/plugins/otbprovider/tests/testdata/polys.gml new file mode 100644 index 000000000000..346f73c4e107 --- /dev/null +++ b/python/plugins/otbprovider/tests/testdata/polys.gml @@ -0,0 +1,58 @@ + + + + + -1-3 + 106 + + + + + + -1,-1 -1,3 3,3 3,2 2,2 2,-1 -1,-1 + aaaaa + 33 + 44.123456 + + + + + 5,5 6,4 4,4 5,5 + Aaaaa + -33 + 0 + + + + + 2,5 2,6 3,6 3,5 2,5 + bbaaa + 0.123 + + + + + 6,1 10,1 10,-3 6,-3 6,17,0 7,-2 9,-2 9,0 7,0 + ASDF + 0 + + + + + 120 + -100291.43213 + + + + + 3,2 6,1 6,-3 2,-1 2,2 3,2 + elim + 2 + 3.33 + + + diff --git a/python/plugins/otbprovider/tests/testdata/raster with spaces.tif b/python/plugins/otbprovider/tests/testdata/raster with spaces.tif new file mode 100644 index 000000000000..14ad3684b279 Binary files /dev/null and b/python/plugins/otbprovider/tests/testdata/raster with spaces.tif differ diff --git a/python/plugins/otbprovider/tests/testdata/raster with spaces.tif.aux.xml b/python/plugins/otbprovider/tests/testdata/raster with spaces.tif.aux.xml new file mode 100644 index 000000000000..b80b3ef02d6c --- /dev/null +++ b/python/plugins/otbprovider/tests/testdata/raster with spaces.tif.aux.xml @@ -0,0 +1,11 @@ + + + + 899 + 865.86666666667 + 826 + 17.808206597584 + 53.57 + + + diff --git a/python/plugins/otbprovider/tests/testdata/raster.tif b/python/plugins/otbprovider/tests/testdata/raster.tif new file mode 100644 index 000000000000..14ad3684b279 Binary files /dev/null and b/python/plugins/otbprovider/tests/testdata/raster.tif differ diff --git a/python/plugins/otbprovider/tests/testdata/raster.tif.aux.xml b/python/plugins/otbprovider/tests/testdata/raster.tif.aux.xml new file mode 100644 index 000000000000..b80b3ef02d6c --- /dev/null +++ b/python/plugins/otbprovider/tests/testdata/raster.tif.aux.xml @@ -0,0 +1,11 @@ + + + + 899 + 865.86666666667 + 826 + 17.808206597584 + 53.57 + + + diff --git a/python/plugins/processing/tests/CMakeLists.txt b/python/plugins/processing/tests/CMakeLists.txt index 415f8d858d0a..90ef16c2ba41 100644 --- a/python/plugins/processing/tests/CMakeLists.txt +++ b/python/plugins/processing/tests/CMakeLists.txt @@ -31,7 +31,6 @@ if(ENABLE_TESTS) ADD_PYTHON_TEST(ProcessingGrass7AlgorithmsRasterTestPt1 Grass7AlgorithmsRasterTestPt1.py) ADD_PYTHON_TEST(ProcessingGrass7AlgorithmsRasterTestPt2 Grass7AlgorithmsRasterTestPt2.py) ADD_PYTHON_TEST(ProcessingGrass7AlgorithmsVectorTest Grass7AlgorithmsVectorTest.py) - ADD_PYTHON_TEST(ProcessingOtbAlgorithmsTest OtbAlgorithmsTest.py) ADD_PYTHON_TEST(ProcessingCheckValidityAlgorithmTest CheckValidityAlgorithm.py) ADD_PYTHON_TEST(ProcessingScriptUtilsTest ScriptUtilsTest.py) if(ENABLE_SAGA_TESTS)