AlgorithmsTestBase.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. """
  2. ***************************************************************************
  3. AlgorithmsTest.py
  4. ---------------------
  5. Date : January 2016
  6. Copyright : (C) 2016 by Matthias Kuhn
  7. Email : matthias@opengis.ch
  8. ***************************************************************************
  9. * *
  10. * This program is free software; you can redistribute it and/or modify *
  11. * it under the terms of the GNU General Public License as published by *
  12. * the Free Software Foundation; either version 2 of the License, or *
  13. * (at your option) any later version. *
  14. * *
  15. ***************************************************************************
  16. """
  17. __author__ = 'Matthias Kuhn'
  18. __date__ = 'January 2016'
  19. __copyright__ = '(C) 2016, Matthias Kuhn'
  20. import qgis # NOQA switch sip api
  21. import os
  22. import yaml
  23. import nose2
  24. import shutil
  25. import glob
  26. import hashlib
  27. import tempfile
  28. import re
  29. from osgeo import gdal
  30. from osgeo.gdalconst import GA_ReadOnly
  31. from numpy import nan_to_num
  32. from copy import deepcopy
  33. from qgis.PyQt.QtCore import QT_VERSION
  34. from qgis.core import (Qgis,
  35. QgsVectorLayer,
  36. QgsRasterLayer,
  37. QgsCoordinateReferenceSystem,
  38. QgsFeatureRequest,
  39. QgsMapLayer,
  40. QgsProject,
  41. QgsApplication,
  42. QgsProcessingContext,
  43. QgsProcessingUtils,
  44. QgsProcessingFeedback)
  45. from qgis.analysis import (QgsNativeAlgorithms)
  46. from qgis.testing import (_UnexpectedSuccess,
  47. start_app,
  48. unittest)
  49. from utilities import unitTestDataPath
  50. import processing
  51. def GDAL_COMPUTE_VERSION(maj, min, rev):
  52. return ((maj) * 1000000 + (min) * 10000 + (rev) * 100)
  53. def processingTestDataPath():
  54. return os.path.join(os.path.dirname(__file__), 'testdata')
  55. class AlgorithmsTest:
  56. def test_algorithms(self):
  57. """
  58. This is the main test function. All others will be executed based on the definitions in testdata/algorithm_tests.yaml
  59. """
  60. with open(os.path.join(processingTestDataPath(), self.test_definition_file())) as stream:
  61. algorithm_tests = yaml.load(stream, Loader=yaml.SafeLoader)
  62. if 'tests' in algorithm_tests and algorithm_tests['tests'] is not None:
  63. for idx, algtest in enumerate(algorithm_tests['tests']):
  64. condition = algtest.get('condition')
  65. if condition:
  66. geos_condition = condition.get('geos')
  67. if geos_condition:
  68. less_than_condition = geos_condition.get('less_than')
  69. if less_than_condition:
  70. if Qgis.geosVersionInt() >= less_than_condition:
  71. print('!!! Skipping {}, requires GEOS < {}, have version {}'.format(algtest['name'], less_than_condition, Qgis.geosVersionInt()))
  72. continue
  73. at_least_condition = geos_condition.get('at_least')
  74. if at_least_condition:
  75. if Qgis.geosVersionInt() < at_least_condition:
  76. print('!!! Skipping {}, requires GEOS >= {}, have version {}'.format(algtest['name'], at_least_condition, Qgis.geosVersionInt()))
  77. continue
  78. gdal_condition = condition.get('gdal')
  79. if gdal_condition:
  80. less_than_condition = gdal_condition.get('less_than')
  81. if less_than_condition:
  82. if int(gdal.VersionInfo('VERSION_NUM')) >= less_than_condition:
  83. print('!!! Skipping {}, requires GDAL < {}, have version {}'.format(algtest['name'], less_than_condition, gdal.VersionInfo('VERSION_NUM')))
  84. continue
  85. at_least_condition = gdal_condition.get('at_least')
  86. if at_least_condition:
  87. if int(gdal.VersionInfo('VERSION_NUM')) < at_least_condition:
  88. print('!!! Skipping {}, requires GDAL >= {}, have version {}'.format(algtest['name'], at_least_condition, gdal.VersionInfo('VERSION_NUM')))
  89. continue
  90. qt_condition = condition.get('qt')
  91. if qt_condition:
  92. less_than_condition = qt_condition.get('less_than')
  93. if less_than_condition:
  94. if QT_VERSION >= less_than_condition:
  95. print('!!! Skipping {}, requires Qt < {}, have version {}'.format(algtest['name'], less_than_condition, QT_VERSION))
  96. continue
  97. at_least_condition = qt_condition.get('at_least')
  98. if at_least_condition:
  99. if QT_VERSION < at_least_condition:
  100. print('!!! Skipping {}, requires Qt >= {}, have version {}'.format(algtest['name'], at_least_condition, QT_VERSION))
  101. continue
  102. print('About to start {} of {}: "{}"'.format(idx, len(algorithm_tests['tests']), algtest['name']))
  103. yield self.check_algorithm, algtest['name'], algtest
  104. def check_algorithm(self, name, defs):
  105. """
  106. Will run an algorithm definition and check if it generates the expected result
  107. :param name: The identifier name used in the test output heading
  108. :param defs: A python dict containing a test algorithm definition
  109. """
  110. self.vector_layer_params = {}
  111. QgsProject.instance().clear()
  112. if 'project' in defs:
  113. full_project_path = os.path.join(processingTestDataPath(), defs['project'])
  114. project_read_success = QgsProject.instance().read(full_project_path)
  115. self.assertTrue(project_read_success, 'Failed to load project file: ' + defs['project'])
  116. if 'project_crs' in defs:
  117. QgsProject.instance().setCrs(QgsCoordinateReferenceSystem(defs['project_crs']))
  118. else:
  119. QgsProject.instance().setCrs(QgsCoordinateReferenceSystem())
  120. if 'ellipsoid' in defs:
  121. QgsProject.instance().setEllipsoid(defs['ellipsoid'])
  122. else:
  123. QgsProject.instance().setEllipsoid('')
  124. params = self.load_params(defs['params'])
  125. print('Running alg: "{}"'.format(defs['algorithm']))
  126. alg = QgsApplication.processingRegistry().createAlgorithmById(defs['algorithm'])
  127. parameters = {}
  128. if isinstance(params, list):
  129. for param in zip(alg.parameterDefinitions(), params):
  130. parameters[param[0].name()] = param[1]
  131. else:
  132. for k, p in params.items():
  133. parameters[k] = p
  134. for r, p in list(defs['results'].items()):
  135. if 'in_place_result' not in p or not p['in_place_result']:
  136. parameters[r] = self.load_result_param(p)
  137. expectFailure = False
  138. if 'expectedFailure' in defs:
  139. exec(('\n'.join(defs['expectedFailure'][:-1])), globals(), locals())
  140. expectFailure = eval(defs['expectedFailure'][-1])
  141. if 'expectedException' in defs:
  142. expectFailure = True
  143. # ignore user setting for invalid geometry handling
  144. context = QgsProcessingContext()
  145. context.setProject(QgsProject.instance())
  146. if 'ellipsoid' in defs:
  147. # depending on the project settings, we can't always rely
  148. # on QgsProject.ellipsoid() returning the same ellipsoid as was
  149. # specified in the test definition. So just force ensure that the
  150. # context's ellipsoid is the desired one
  151. context.setEllipsoid(defs['ellipsoid'])
  152. if 'skipInvalid' in defs and defs['skipInvalid']:
  153. context.setInvalidGeometryCheck(QgsFeatureRequest.GeometrySkipInvalid)
  154. feedback = QgsProcessingFeedback()
  155. print(f'Algorithm parameters are {parameters}')
  156. # first check that algorithm accepts the parameters we pass...
  157. ok, msg = alg.checkParameterValues(parameters, context)
  158. self.assertTrue(ok, f'Algorithm failed checkParameterValues with result {msg}')
  159. if expectFailure:
  160. try:
  161. results, ok = alg.run(parameters, context, feedback)
  162. self.check_results(results, context, parameters, defs['results'])
  163. if ok:
  164. raise _UnexpectedSuccess
  165. except Exception:
  166. pass
  167. else:
  168. results, ok = alg.run(parameters, context, feedback)
  169. self.assertTrue(ok, f'params: {parameters}, results: {results}')
  170. self.check_results(results, context, parameters, defs['results'])
  171. def load_params(self, params):
  172. """
  173. Loads an array of parameters
  174. """
  175. if isinstance(params, list):
  176. return [self.load_param(p) for p in params]
  177. elif isinstance(params, dict):
  178. return {key: self.load_param(p, key) for key, p in params.items()}
  179. else:
  180. return params
  181. def load_param(self, param, id=None):
  182. """
  183. 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
  184. parameter based on its key `type` and return the appropriate parameter to pass to the algorithm.
  185. """
  186. try:
  187. if param['type'] in ('vector', 'raster', 'table'):
  188. return self.load_layer(id, param).id()
  189. elif param['type'] == 'vrtlayers':
  190. vals = []
  191. for p in param['params']:
  192. p['layer'] = self.load_layer(None, {'type': 'vector', 'name': p['layer']})
  193. vals.append(p)
  194. return vals
  195. elif param['type'] == 'multi':
  196. return [self.load_param(p) for p in param['params']]
  197. elif param['type'] == 'file':
  198. return self.filepath_from_param(param)
  199. elif param['type'] == 'interpolation':
  200. prefix = processingTestDataPath()
  201. tmp = ''
  202. for r in param['name'].split('::|::'):
  203. v = r.split('::~::')
  204. tmp += '{}::~::{}::~::{}::~::{};'.format(os.path.join(prefix, v[0]),
  205. v[1], v[2], v[3])
  206. return tmp[:-1]
  207. except TypeError:
  208. # No type specified, use whatever is there
  209. return param
  210. raise KeyError("Unknown type '{}' specified for parameter".format(param['type']))
  211. def load_result_param(self, param):
  212. """
  213. Loads a result parameter. Creates a temporary destination where the result should go to and returns this location
  214. so it can be sent to the algorithm as parameter.
  215. """
  216. if param['type'] in ['vector', 'file', 'table', 'regex']:
  217. outdir = tempfile.mkdtemp()
  218. self.cleanup_paths.append(outdir)
  219. if isinstance(param['name'], str):
  220. basename = os.path.basename(param['name'])
  221. else:
  222. basename = os.path.basename(param['name'][0])
  223. filepath = self.uri_path_join(outdir, basename)
  224. return filepath
  225. elif param['type'] == 'rasterhash':
  226. outdir = tempfile.mkdtemp()
  227. self.cleanup_paths.append(outdir)
  228. basename = 'raster.tif'
  229. filepath = os.path.join(outdir, basename)
  230. return filepath
  231. elif param['type'] == 'directory':
  232. outdir = tempfile.mkdtemp()
  233. return outdir
  234. raise KeyError("Unknown type '{}' specified for parameter".format(param['type']))
  235. def load_layers(self, id, param):
  236. layers = []
  237. if param['type'] in ('vector', 'table'):
  238. if isinstance(param['name'], str) or 'uri' in param:
  239. layers.append(self.load_layer(id, param))
  240. else:
  241. for n in param['name']:
  242. layer_param = deepcopy(param)
  243. layer_param['name'] = n
  244. layers.append(self.load_layer(id, layer_param))
  245. else:
  246. layers.append(self.load_layer(id, param))
  247. return layers
  248. def load_layer(self, id, param):
  249. """
  250. Loads a layer which was specified as parameter.
  251. """
  252. filepath = self.filepath_from_param(param)
  253. if 'in_place' in param and param['in_place']:
  254. # check if alg modifies layer in place
  255. tmpdir = tempfile.mkdtemp()
  256. self.cleanup_paths.append(tmpdir)
  257. path, file_name = os.path.split(filepath)
  258. base, ext = os.path.splitext(file_name)
  259. for file in glob.glob(os.path.join(path, f'{base}.*')):
  260. shutil.copy(os.path.join(path, file), tmpdir)
  261. filepath = os.path.join(tmpdir, file_name)
  262. self.in_place_layers[id] = filepath
  263. if param['type'] in ('vector', 'table'):
  264. gmlrex = r'\.gml\b'
  265. if re.search(gmlrex, filepath, re.IGNORECASE):
  266. # ewwwww - we have to force SRS detection for GML files, otherwise they'll be loaded
  267. # with no srs
  268. filepath += '|option:FORCE_SRS_DETECTION=YES'
  269. if filepath in self.vector_layer_params:
  270. return self.vector_layer_params[filepath]
  271. options = QgsVectorLayer.LayerOptions()
  272. options.loadDefaultStyle = False
  273. lyr = QgsVectorLayer(filepath, param['name'], 'ogr', options)
  274. self.vector_layer_params[filepath] = lyr
  275. elif param['type'] == 'raster':
  276. options = QgsRasterLayer.LayerOptions()
  277. options.loadDefaultStyle = False
  278. lyr = QgsRasterLayer(filepath, param['name'], 'gdal', options)
  279. self.assertTrue(lyr.isValid(), f'Could not load layer "{filepath}" from param {param}')
  280. QgsProject.instance().addMapLayer(lyr)
  281. return lyr
  282. def filepath_from_param(self, param):
  283. """
  284. Creates a filepath from a param
  285. """
  286. prefix = processingTestDataPath()
  287. if 'location' in param and param['location'] == 'qgs':
  288. prefix = unitTestDataPath()
  289. if 'uri' in param:
  290. path = param['uri']
  291. else:
  292. path = param['name']
  293. if not path:
  294. return None
  295. return self.uri_path_join(prefix, path)
  296. def uri_path_join(self, prefix, filepath):
  297. if filepath.startswith('ogr:'):
  298. if not prefix[-1] == os.path.sep:
  299. prefix += os.path.sep
  300. filepath = re.sub(r"dbname='", f"dbname='{prefix}", filepath)
  301. else:
  302. filepath = os.path.join(prefix, filepath)
  303. return filepath
  304. def check_results(self, results, context, params, expected):
  305. """
  306. Checks if result produced by an algorithm matches with the expected specification.
  307. """
  308. for id, expected_result in expected.items():
  309. if expected_result['type'] in ('vector', 'table'):
  310. if 'compare' in expected_result and not expected_result['compare']:
  311. # skipping the comparison, so just make sure output is valid
  312. if isinstance(results[id], QgsMapLayer):
  313. result_lyr = results[id]
  314. else:
  315. result_lyr = QgsProcessingUtils.mapLayerFromString(results[id], context)
  316. self.assertTrue(result_lyr.isValid())
  317. continue
  318. expected_lyrs = self.load_layers(id, expected_result)
  319. if 'in_place_result' in expected_result:
  320. result_lyr = QgsProcessingUtils.mapLayerFromString(self.in_place_layers[id], context)
  321. self.assertTrue(result_lyr.isValid(), self.in_place_layers[id])
  322. else:
  323. try:
  324. results[id]
  325. except KeyError as e:
  326. raise KeyError(f'Expected result {str(e)} does not exist in {list(results.keys())}')
  327. if isinstance(results[id], QgsMapLayer):
  328. result_lyr = results[id]
  329. else:
  330. string = results[id]
  331. gmlrex = r'\.gml\b'
  332. if re.search(gmlrex, string, re.IGNORECASE):
  333. # ewwwww - we have to force SRS detection for GML files, otherwise they'll be loaded
  334. # with no srs
  335. string += '|option:FORCE_SRS_DETECTION=YES'
  336. result_lyr = QgsProcessingUtils.mapLayerFromString(string, context)
  337. self.assertTrue(result_lyr, results[id])
  338. compare = expected_result.get('compare', {})
  339. pk = expected_result.get('pk', None)
  340. if len(expected_lyrs) == 1:
  341. self.assertLayersEqual(expected_lyrs[0], result_lyr, compare=compare, pk=pk)
  342. else:
  343. res = False
  344. for l in expected_lyrs:
  345. if self.checkLayersEqual(l, result_lyr, compare=compare, pk=pk):
  346. res = True
  347. break
  348. self.assertTrue(res, 'Could not find matching layer in expected results')
  349. elif 'rasterhash' == expected_result['type']:
  350. print(f"id:{id} result:{results[id]}")
  351. self.assertTrue(os.path.exists(results[id]), f'File does not exist: {results[id]}, {params}')
  352. dataset = gdal.Open(results[id], GA_ReadOnly)
  353. dataArray = nan_to_num(dataset.ReadAsArray(0))
  354. strhash = hashlib.sha224(dataArray.data).hexdigest()
  355. if not isinstance(expected_result['hash'], str):
  356. self.assertIn(strhash, expected_result['hash'])
  357. else:
  358. self.assertEqual(strhash, expected_result['hash'])
  359. elif 'file' == expected_result['type']:
  360. result_filepath = results[id]
  361. if isinstance(expected_result.get('name'), list):
  362. # test to see if any match expected
  363. for path in expected_result['name']:
  364. expected_filepath = self.filepath_from_param({'name': path})
  365. if self.checkFilesEqual(expected_filepath, result_filepath):
  366. break
  367. else:
  368. expected_filepath = self.filepath_from_param({'name': expected_result['name'][0]})
  369. else:
  370. expected_filepath = self.filepath_from_param(expected_result)
  371. self.assertFilesEqual(expected_filepath, result_filepath)
  372. elif 'directory' == expected_result['type']:
  373. expected_dirpath = self.filepath_from_param(expected_result)
  374. result_dirpath = results[id]
  375. self.assertDirectoriesEqual(expected_dirpath, result_dirpath)
  376. elif 'regex' == expected_result['type']:
  377. with open(results[id]) as file:
  378. data = file.read()
  379. for rule in expected_result.get('rules', []):
  380. self.assertRegex(data, rule)
  381. class GenericAlgorithmsTest(unittest.TestCase):
  382. """
  383. General (non-provider specific) algorithm tests
  384. """
  385. @classmethod
  386. def setUpClass(cls):
  387. start_app()
  388. from processing.core.Processing import Processing
  389. Processing.initialize()
  390. cls.cleanup_paths = []
  391. @classmethod
  392. def tearDownClass(cls):
  393. from processing.core.Processing import Processing
  394. Processing.deinitialize()
  395. for path in cls.cleanup_paths:
  396. shutil.rmtree(path)
  397. def testAlgorithmCompliance(self):
  398. for p in QgsApplication.processingRegistry().providers():
  399. print(f'testing provider {p.id()}')
  400. for a in p.algorithms():
  401. print(f'testing algorithm {a.id()}')
  402. self.check_algorithm(a)
  403. def check_algorithm(self, alg):
  404. # check that calling helpUrl() works without error
  405. alg.helpUrl()
  406. if __name__ == '__main__':
  407. nose2.main()