1# -*- coding: utf-8 -*- 2"""QGIS Unit tests for QgsProject bad layers handling. 3 4.. note:: This program is free software; you can redistribute it and/or modify 5it under the terms of the GNU General Public License as published by 6the Free Software Foundation; either version 2 of the License, or 7(at your option) any later version. 8""" 9from builtins import chr 10from builtins import range 11__author__ = 'Alessandro Pasotti' 12__date__ = '20/10/2018' 13__copyright__ = 'Copyright 2018, The QGIS Project' 14 15import os 16import filecmp 17 18import qgis # NOQA 19 20from qgis.core import (QgsProject, 21 QgsVectorLayer, 22 QgsCoordinateTransform, 23 QgsMapSettings, 24 QgsRasterLayer, 25 QgsMapLayer, 26 QgsRectangle, 27 QgsDataProvider, 28 QgsReadWriteContext, 29 QgsCoordinateReferenceSystem, 30 ) 31from qgis.gui import (QgsLayerTreeMapCanvasBridge, 32 QgsMapCanvas) 33 34from qgis.PyQt.QtGui import QFont, QColor 35from qgis.PyQt.QtTest import QSignalSpy 36from qgis.PyQt.QtCore import QT_VERSION_STR, QTemporaryDir, QSize 37from qgis.PyQt.QtXml import QDomDocument, QDomNode 38 39from qgis.testing import start_app, unittest 40from utilities import (unitTestDataPath, renderMapToImage) 41from shutil import copyfile 42 43app = start_app() 44TEST_DATA_DIR = unitTestDataPath() 45 46 47class TestQgsProjectBadLayers(unittest.TestCase): 48 49 def setUp(self): 50 p = QgsProject.instance() 51 p.removeAllMapLayers() 52 53 @classmethod 54 def getBaseMapSettings(cls): 55 """ 56 :rtype: QgsMapSettings 57 """ 58 ms = QgsMapSettings() 59 crs = QgsCoordinateReferenceSystem('epsg:4326') 60 ms.setBackgroundColor(QColor(152, 219, 249)) 61 ms.setOutputSize(QSize(420, 280)) 62 ms.setOutputDpi(72) 63 ms.setFlag(QgsMapSettings.Antialiasing, True) 64 ms.setFlag(QgsMapSettings.UseAdvancedEffects, False) 65 ms.setFlag(QgsMapSettings.ForceVectorOutput, False) # no caching? 66 ms.setDestinationCrs(crs) 67 return ms 68 69 def _change_data_source(self, layer, datasource, provider_key): 70 """Due to the fact that a project r/w context is not available inside 71 the map layers classes, the original style and subset string restore 72 happens in app, this function replicates app behavior""" 73 74 options = QgsDataProvider.ProviderOptions() 75 76 subset_string = '' 77 if not layer.isValid(): 78 try: 79 subset_string = layer.dataProvider().subsetString() 80 except: 81 pass 82 83 layer.setDataSource(datasource, layer.name(), provider_key, options) 84 85 if subset_string: 86 layer.setSubsetString(subset_string) 87 88 self.assertTrue(layer.originalXmlProperties(), layer.name()) 89 context = QgsReadWriteContext() 90 context.setPathResolver(QgsProject.instance().pathResolver()) 91 errorMsg = '' 92 doc = QDomDocument() 93 self.assertTrue(doc.setContent(layer.originalXmlProperties())) 94 layer_node = QDomNode(doc.firstChild()) 95 self.assertTrue(layer.readSymbology(layer_node, errorMsg, context)) 96 97 def test_project_roundtrip(self): 98 """Tests that a project with bad layers can be saved and restored""" 99 100 p = QgsProject.instance() 101 temp_dir = QTemporaryDir() 102 for ext in ('shp', 'dbf', 'shx', 'prj'): 103 copyfile(os.path.join(TEST_DATA_DIR, 'lines.%s' % ext), os.path.join(temp_dir.path(), 'lines.%s' % ext)) 104 copyfile(os.path.join(TEST_DATA_DIR, 'raster', 'band1_byte_ct_epsg4326.tif'), os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326.tif')) 105 copyfile(os.path.join(TEST_DATA_DIR, 'raster', 'band1_byte_ct_epsg4326.tif'), os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326_copy.tif')) 106 l = QgsVectorLayer(os.path.join(temp_dir.path(), 'lines.shp'), 'lines', 'ogr') 107 self.assertTrue(l.isValid()) 108 109 rl = QgsRasterLayer(os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326.tif'), 'raster', 'gdal') 110 self.assertTrue(rl.isValid()) 111 rl_copy = QgsRasterLayer(os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326_copy.tif'), 'raster_copy', 'gdal') 112 self.assertTrue(rl_copy.isValid()) 113 self.assertTrue(p.addMapLayers([l, rl, rl_copy])) 114 115 # Save project 116 project_path = os.path.join(temp_dir.path(), 'project.qgs') 117 self.assertTrue(p.write(project_path)) 118 119 # Re-load the project, checking for the XML properties 120 p.removeAllMapLayers() 121 self.assertTrue(p.read(project_path)) 122 vector = list(p.mapLayersByName('lines'))[0] 123 raster = list(p.mapLayersByName('raster'))[0] 124 raster_copy = list(p.mapLayersByName('raster_copy'))[0] 125 self.assertTrue(vector.originalXmlProperties() != '') 126 self.assertTrue(raster.originalXmlProperties() != '') 127 self.assertTrue(raster_copy.originalXmlProperties() != '') 128 # Test setter 129 raster.setOriginalXmlProperties('pippo') 130 self.assertEqual(raster.originalXmlProperties(), 'pippo') 131 132 # Now create an invalid project: 133 bad_project_path = os.path.join(temp_dir.path(), 'project_bad.qgs') 134 with open(project_path, 'r') as infile: 135 with open(bad_project_path, 'w+') as outfile: 136 outfile.write(infile.read().replace('./lines.shp', './lines-BAD_SOURCE.shp').replace('band1_byte_ct_epsg4326_copy.tif', 'band1_byte_ct_epsg4326_copy-BAD_SOURCE.tif')) 137 138 # Load the bad project 139 p.removeAllMapLayers() 140 self.assertTrue(p.read(bad_project_path)) 141 # Check layer is invalid 142 vector = list(p.mapLayersByName('lines'))[0] 143 raster = list(p.mapLayersByName('raster'))[0] 144 raster_copy = list(p.mapLayersByName('raster_copy'))[0] 145 self.assertIsNotNone(vector.dataProvider()) 146 self.assertIsNotNone(raster.dataProvider()) 147 self.assertIsNotNone(raster_copy.dataProvider()) 148 self.assertFalse(vector.isValid()) 149 self.assertFalse(raster_copy.isValid()) 150 # Try a getFeatures 151 self.assertEqual([f for f in vector.getFeatures()], []) 152 self.assertTrue(raster.isValid()) 153 self.assertEqual(vector.providerType(), 'ogr') 154 155 # Save the project 156 bad_project_path2 = os.path.join(temp_dir.path(), 'project_bad2.qgs') 157 p.write(bad_project_path2) 158 # Re-save the project, with fixed paths 159 good_project_path = os.path.join(temp_dir.path(), 'project_good.qgs') 160 with open(bad_project_path2, 'r') as infile: 161 with open(good_project_path, 'w+') as outfile: 162 outfile.write(infile.read().replace('./lines-BAD_SOURCE.shp', './lines.shp').replace('band1_byte_ct_epsg4326_copy-BAD_SOURCE.tif', 'band1_byte_ct_epsg4326_copy.tif')) 163 164 # Load the good project 165 p.removeAllMapLayers() 166 self.assertTrue(p.read(good_project_path)) 167 # Check layer is valid 168 vector = list(p.mapLayersByName('lines'))[0] 169 raster = list(p.mapLayersByName('raster'))[0] 170 raster_copy = list(p.mapLayersByName('raster_copy'))[0] 171 self.assertTrue(vector.isValid()) 172 self.assertTrue(raster.isValid()) 173 self.assertTrue(raster_copy.isValid()) 174 175 def test_project_relations(self): 176 """Tests that a project with bad layers and relations can be saved with relations""" 177 178 temp_dir = QTemporaryDir() 179 p = QgsProject.instance() 180 for ext in ('qgs', 'gpkg'): 181 copyfile(os.path.join(TEST_DATA_DIR, 'projects', 'relation_reference_test.%s' % ext), os.path.join(temp_dir.path(), 'relation_reference_test.%s' % ext)) 182 183 # Load the good project 184 project_path = os.path.join(temp_dir.path(), 'relation_reference_test.qgs') 185 p.removeAllMapLayers() 186 self.assertTrue(p.read(project_path)) 187 point_a = list(p.mapLayersByName('point_a'))[0] 188 point_b = list(p.mapLayersByName('point_b'))[0] 189 point_a_source = point_a.publicSource() 190 point_b_source = point_b.publicSource() 191 self.assertTrue(point_a.isValid()) 192 self.assertTrue(point_b.isValid()) 193 194 # Check relations 195 def _check_relations(): 196 relation = list(p.relationManager().relations().values())[0] 197 self.assertTrue(relation.isValid()) 198 self.assertEqual(relation.referencedLayer().id(), point_b.id()) 199 self.assertEqual(relation.referencingLayer().id(), point_a.id()) 200 201 _check_relations() 202 203 # Now build a bad project 204 bad_project_path = os.path.join(temp_dir.path(), 'relation_reference_test_bad.qgs') 205 with open(project_path, 'r') as infile: 206 with open(bad_project_path, 'w+') as outfile: 207 outfile.write(infile.read().replace('./relation_reference_test.gpkg', './relation_reference_test-BAD_SOURCE.gpkg')) 208 209 # Load the bad project 210 p.removeAllMapLayers() 211 self.assertTrue(p.read(bad_project_path)) 212 point_a = list(p.mapLayersByName('point_a'))[0] 213 point_b = list(p.mapLayersByName('point_b'))[0] 214 self.assertFalse(point_a.isValid()) 215 self.assertFalse(point_b.isValid()) 216 217 # This fails because relations are not valid anymore 218 with self.assertRaises(AssertionError): 219 _check_relations() 220 221 # Changing data source, relations should be restored: 222 options = QgsDataProvider.ProviderOptions() 223 point_a.setDataSource(point_a_source, 'point_a', 'ogr', options) 224 point_b.setDataSource(point_b_source, 'point_b', 'ogr', options) 225 self.assertTrue(point_a.isValid()) 226 self.assertTrue(point_b.isValid()) 227 228 # Check if relations were restored 229 _check_relations() 230 231 # Reload the bad project 232 p.removeAllMapLayers() 233 self.assertTrue(p.read(bad_project_path)) 234 point_a = list(p.mapLayersByName('point_a'))[0] 235 point_b = list(p.mapLayersByName('point_b'))[0] 236 self.assertFalse(point_a.isValid()) 237 self.assertFalse(point_b.isValid()) 238 239 # This fails because relations are not valid anymore 240 with self.assertRaises(AssertionError): 241 _check_relations() 242 243 # Save the bad project 244 bad_project_path2 = os.path.join(temp_dir.path(), 'relation_reference_test_bad2.qgs') 245 p.write(bad_project_path2) 246 247 # Now fix the bad project 248 bad_project_path_fixed = os.path.join(temp_dir.path(), 'relation_reference_test_bad_fixed.qgs') 249 with open(bad_project_path2, 'r') as infile: 250 with open(bad_project_path_fixed, 'w+') as outfile: 251 outfile.write(infile.read().replace('./relation_reference_test-BAD_SOURCE.gpkg', './relation_reference_test.gpkg')) 252 253 # Load the fixed project 254 p.removeAllMapLayers() 255 self.assertTrue(p.read(bad_project_path_fixed)) 256 point_a = list(p.mapLayersByName('point_a'))[0] 257 point_b = list(p.mapLayersByName('point_b'))[0] 258 point_a_source = point_a.publicSource() 259 point_b_source = point_b.publicSource() 260 self.assertTrue(point_a.isValid()) 261 self.assertTrue(point_b.isValid()) 262 _check_relations() 263 264 def testStyles(self): 265 """Test that styles for rasters and vectors are kept when setDataSource is called""" 266 267 temp_dir = QTemporaryDir() 268 p = QgsProject.instance() 269 for f in ( 270 'bad_layer_raster_test.tfw', 271 'bad_layer_raster_test.tiff', 272 'bad_layer_raster_test.tiff.aux.xml', 273 'bad_layers_test.gpkg', 274 'good_layers_test.qgs'): 275 copyfile(os.path.join(TEST_DATA_DIR, 'projects', f), os.path.join(temp_dir.path(), f)) 276 277 project_path = os.path.join(temp_dir.path(), 'good_layers_test.qgs') 278 p = QgsProject().instance() 279 p.removeAllMapLayers() 280 self.assertTrue(p.read(project_path)) 281 self.assertEqual(p.count(), 4) 282 283 ms = self.getBaseMapSettings() 284 point_a_copy = list(p.mapLayersByName('point_a copy'))[0] 285 point_a = list(p.mapLayersByName('point_a'))[0] 286 point_b = list(p.mapLayersByName('point_b'))[0] 287 raster = list(p.mapLayersByName('bad_layer_raster_test'))[0] 288 self.assertTrue(point_a_copy.isValid()) 289 self.assertTrue(point_a.isValid()) 290 self.assertTrue(point_b.isValid()) 291 self.assertTrue(raster.isValid()) 292 ms.setExtent(QgsRectangle(2.81861, 41.98138, 2.81952, 41.9816)) 293 ms.setLayers([point_a_copy, point_a, point_b, raster]) 294 image = renderMapToImage(ms) 295 self.assertTrue(image.save(os.path.join(temp_dir.path(), 'expected.png'), 'PNG')) 296 297 point_a_source = point_a.publicSource() 298 point_b_source = point_b.publicSource() 299 raster_source = raster.publicSource() 300 self._change_data_source(point_a, point_a_source, 'ogr') 301 # Attention: we are not passing the subset string here: 302 self._change_data_source(point_a_copy, point_a_source, 'ogr') 303 self._change_data_source(point_b, point_b_source, 'ogr') 304 self._change_data_source(raster, raster_source, 'gdal') 305 self.assertTrue(image.save(os.path.join(temp_dir.path(), 'actual.png'), 'PNG')) 306 307 self.assertTrue(filecmp.cmp(os.path.join(temp_dir.path(), 'actual.png'), os.path.join(temp_dir.path(), 'expected.png')), False) 308 309 # Now build a bad project 310 p.removeAllMapLayers() 311 bad_project_path = os.path.join(temp_dir.path(), 'bad_layers_test.qgs') 312 with open(project_path, 'r') as infile: 313 with open(bad_project_path, 'w+') as outfile: 314 outfile.write(infile.read().replace('./bad_layers_test.', './bad_layers_test-BAD_SOURCE.').replace('bad_layer_raster_test.tiff', 'bad_layer_raster_test-BAD_SOURCE.tiff')) 315 316 p.removeAllMapLayers() 317 self.assertTrue(p.read(bad_project_path)) 318 self.assertEqual(p.count(), 4) 319 point_a_copy = list(p.mapLayersByName('point_a copy'))[0] 320 point_a = list(p.mapLayersByName('point_a'))[0] 321 point_b = list(p.mapLayersByName('point_b'))[0] 322 raster = list(p.mapLayersByName('bad_layer_raster_test'))[0] 323 self.assertFalse(point_a.isValid()) 324 self.assertFalse(point_a_copy.isValid()) 325 self.assertFalse(point_b.isValid()) 326 self.assertFalse(raster.isValid()) 327 ms.setLayers([point_a_copy, point_a, point_b, raster]) 328 image = renderMapToImage(ms) 329 self.assertTrue(image.save(os.path.join(temp_dir.path(), 'bad.png'), 'PNG')) 330 self.assertFalse(filecmp.cmp(os.path.join(temp_dir.path(), 'bad.png'), os.path.join(temp_dir.path(), 'expected.png')), False) 331 332 self._change_data_source(point_a, point_a_source, 'ogr') 333 # We are not passing the subset string!! 334 self._change_data_source(point_a_copy, point_a_source, 'ogr') 335 self._change_data_source(point_b, point_b_source, 'ogr') 336 self._change_data_source(raster, raster_source, 'gdal') 337 self.assertTrue(point_a.isValid()) 338 self.assertTrue(point_a_copy.isValid()) 339 self.assertTrue(point_b.isValid()) 340 self.assertTrue(raster.isValid()) 341 342 ms.setLayers([point_a_copy, point_a, point_b, raster]) 343 image = renderMapToImage(ms) 344 self.assertTrue(image.save(os.path.join(temp_dir.path(), 'actual_fixed.png'), 'PNG')) 345 346 self.assertTrue(filecmp.cmp(os.path.join(temp_dir.path(), 'actual_fixed.png'), os.path.join(temp_dir.path(), 'expected.png')), False) 347 348 349if __name__ == '__main__': 350 unittest.main() 351