fakeqgis.py (14619B)
1 """ 2 Minimal fake qgis installed into sys.modules so that profile_interpreter 3 can be imported and unit-tested on a QGIS-free Python runtime (e.g. CI). 4 """ 5 import sys 6 from types import ModuleType 7 8 9 # ── PyQt stubs ──────────────────────────────────────────────────────────── 10 11 class Qt: 12 class MouseButton: 13 LeftButton = 1 14 RightButton = 2 15 LeftButton = 1 16 RightButton = 2 17 18 19 class QPointF: 20 def __init__(self, x=0.0, y=0.0): 21 self._x = x 22 self._y = y 23 24 def x(self): 25 return self._x 26 27 def y(self): 28 return self._y 29 30 31 class QMetaType: 32 class Type: 33 Int = 1 34 Double = 2 35 QString = 3 36 37 38 class QEvent: 39 class Type: 40 ChildAdded = 68 41 Show = 17 42 43 def __init__(self, event_type): 44 self._type = event_type 45 46 def type(self): 47 return self._type 48 49 50 class QObject: 51 def __init__(self, parent=None): 52 pass 53 54 55 class QTimer: 56 @staticmethod 57 def singleShot(msec, callback): 58 callback() 59 60 61 class QIcon: 62 def __init__(self, *args, **kwargs): 63 pass 64 65 66 class _FakeSignal: 67 def __init__(self): 68 self._callbacks = [] 69 70 def connect(self, cb): 71 self._callbacks.append(cb) 72 73 def emit(self, *args): 74 for cb in list(self._callbacks): 75 cb(*args) 76 77 78 class QAction: 79 def __init__(self, *args, **kwargs): 80 self._checkable = False 81 self._checked = False 82 self._enabled = True 83 self._tooltip = '' 84 self.toggled = _FakeSignal() 85 self.triggered = _FakeSignal() 86 87 def setCheckable(self, v): 88 self._checkable = v 89 90 def setChecked(self, v): 91 if v == self._checked: 92 return 93 self._checked = v 94 self.toggled.emit(v) 95 96 def isChecked(self): 97 return self._checked 98 99 def setEnabled(self, v): 100 self._enabled = v 101 102 def isEnabled(self): 103 return self._enabled 104 105 def setToolTip(self, v): 106 self._tooltip = v 107 108 109 class QToolBar: 110 def __init__(self, parent=None): 111 self.actions_added = [] 112 113 def addAction(self, action): 114 self.actions_added.append(action) 115 116 def removeAction(self, action): 117 if action in self.actions_added: 118 self.actions_added.remove(action) 119 120 def actions(self): 121 return list(self.actions_added) 122 123 124 # ── qgis.core stubs ─────────────────────────────────────────────────────── 125 126 class Qgis: 127 Warning = 1 128 Info = 2 129 Success = 3 130 131 132 class QgsApplication: 133 @staticmethod 134 def getThemeIcon(name): 135 return QIcon() 136 137 138 class QgsField: 139 def __init__(self, name, type_=None): 140 self._name = name 141 142 def name(self): 143 return self._name 144 145 146 class _FakeFields: 147 def __init__(self, fields): 148 self._fields = list(fields) 149 150 def __iter__(self): 151 return iter(self._fields) 152 153 def indexOf(self, name): 154 names = [f.name() for f in self._fields] 155 return names.index(name) if name in names else -1 156 157 158 class QgsFeature: 159 def __init__(self, fields=None): 160 self._fields = fields if fields is not None else _FakeFields([]) 161 self._attrs = {} 162 self._geom = None 163 self._id = -1 164 165 def id(self): 166 return self._id 167 168 def setId(self, fid): 169 self._id = fid 170 171 def fields(self): 172 return self._fields 173 174 def geometry(self): 175 return self._geom if self._geom is not None else QgsGeometry() 176 177 def setGeometry(self, geom): 178 self._geom = geom 179 180 def __setitem__(self, key, value): 181 self._attrs[key] = value 182 183 def __getitem__(self, key): 184 return self._attrs[key] 185 186 187 class _FakeGeometry: 188 def __init__(self, empty=False, x=100.0, y=200.0): 189 self._empty = empty 190 self._x = x 191 self._y = y 192 193 def isEmpty(self): 194 return self._empty 195 196 def asPoint(self): 197 return _FakeXY(self._x, self._y) 198 199 def clone(self): 200 return self 201 202 203 class _FakeXY: 204 def __init__(self, x=100.0, y=200.0): 205 self._x = x 206 self._y = y 207 208 def x(self): 209 return self._x 210 211 def y(self): 212 return self._y 213 214 215 class QgsPointXY: 216 def __init__(self, x=0.0, y=0.0): 217 self._x = x 218 self._y = y 219 220 def x(self): 221 return self._x 222 223 def y(self): 224 return self._y 225 226 227 class QgsGeometry: 228 # Set to True in tests to make interpolate() return an empty geometry. 229 _next_interpolate_empty = False 230 # Lateral offset of every point from the profile curve, as seen by distance(). 231 _lateral_distance = 0.0 232 233 def __init__(self, obj=None): 234 self._obj = obj 235 236 @classmethod 237 def set_interpolate_empty(cls, v): 238 cls._next_interpolate_empty = v 239 240 def interpolate(self, distance): 241 empty = QgsGeometry._next_interpolate_empty 242 return _FakeGeometry(empty=empty) 243 244 def isEmpty(self): 245 return False 246 247 def asPoint(self): 248 return _FakeXY() 249 250 def clone(self): 251 return self 252 253 def constGet(self): 254 return self._obj 255 256 def lineLocatePoint(self, point_geom): 257 return point_geom.constGet().x() 258 259 def distance(self, other): 260 return QgsGeometry._lateral_distance 261 262 263 class QgsPoint: 264 def __init__(self, x=0.0, y=0.0, z=0.0): 265 self._x = x 266 self._y = y 267 self._z = z 268 269 def x(self): 270 return self._x 271 272 def y(self): 273 return self._y 274 275 def z(self): 276 return self._z 277 278 279 class QgsProfilePoint: 280 def __init__(self, distance=0.0, elevation=0.0): 281 self._distance = distance 282 self._elevation = elevation 283 284 def distance(self): 285 return self._distance 286 287 def elevation(self): 288 return self._elevation 289 290 291 class QgsCoordinateTransform: 292 _calls = [] # (src_authid, dst_authid, in_x, in_y) 293 294 def __init__(self, src, dst, project): 295 self._src = src 296 self._dst = dst 297 298 def transform(self, pt): 299 QgsCoordinateTransform._calls.append( 300 (self._src.authid(), self._dst.authid(), pt.x(), pt.y()) 301 ) 302 return QgsPointXY(pt.x() + 1000.0, pt.y() + 1000.0) 303 304 @classmethod 305 def reset(cls): 306 cls._calls.clear() 307 308 309 class QgsMessageLog: 310 _log = [] 311 312 @classmethod 313 def logMessage(cls, msg, tag='', level=None): 314 cls._log.append((tag, msg, level)) 315 316 @classmethod 317 def reset(cls): 318 cls._log.clear() 319 320 321 class QgsWkbTypes: 322 PointZ = 1001 323 Point = 1 324 PointGeometry = 0 # PyQt5 style 325 LineGeometry = 1 326 PolygonGeometry = 2 327 328 class GeometryType: # PyQt6 style 329 PointGeometry = 0 330 LineGeometry = 1 331 PolygonGeometry = 2 332 333 @staticmethod 334 def hasZ(wkb_type): 335 return wkb_type == QgsWkbTypes.PointZ 336 337 338 class QgsVectorDataProvider: 339 AddFeatures = 1 # PyQt5 style 340 DeleteFeatures = 2 341 ChangeGeometries = 4 342 ChangeAttributeValues = 8 343 344 class Capability: # PyQt6 style 345 AddFeatures = 1 346 DeleteFeatures = 2 347 ChangeGeometries = 4 348 ChangeAttributeValues = 8 349 350 351 class _FakeDataProvider: 352 _fail_next = False # set True in a test to simulate addFeatures failure 353 _fail_next_delete = False # set True in a test to simulate deleteFeatures failure 354 _fail_next_change = False # set True in a test to simulate changeGeometryValues failure 355 356 def __init__(self, caps, features_store, fields_ref): 357 self._caps = caps 358 self._features = features_store 359 self._fields_ref = fields_ref 360 self._next_id = 1 361 362 def capabilities(self): 363 return self._caps 364 365 def addFeatures(self, features): 366 if _FakeDataProvider._fail_next: 367 _FakeDataProvider._fail_next = False 368 return (False, []) 369 for feature in features: 370 feature.setId(self._next_id) 371 self._next_id += 1 372 self._features.extend(features) 373 return (True, list(features)) 374 375 def deleteFeatures(self, ids): 376 if _FakeDataProvider._fail_next_delete: 377 _FakeDataProvider._fail_next_delete = False 378 return False 379 remaining = [f for f in self._features if f.id() not in ids] 380 self._features[:] = remaining 381 return True 382 383 def changeGeometryValues(self, geometries): 384 if _FakeDataProvider._fail_next_change: 385 _FakeDataProvider._fail_next_change = False 386 return False 387 for feature in self._features: 388 if feature.id() in geometries: 389 feature.setGeometry(geometries[feature.id()]) 390 return True 391 392 def changeAttributeValues(self, changes): 393 names = [f.name() for f in self._fields_ref] 394 for feature in self._features: 395 for idx, value in changes.get(feature.id(), {}).items(): 396 feature[names[idx]] = value 397 return True 398 399 def addAttributes(self, attrs): 400 self._fields_ref.extend(attrs) 401 402 403 class QgsVectorLayer: 404 def __init__(self, uri='', name='', provider=''): 405 self._name = name 406 self._features = [] 407 self._fields_list = [] 408 self._geom_type = QgsWkbTypes.PointGeometry 409 self._wkb_type = QgsWkbTypes.PointZ 410 self._provider = _FakeDataProvider( 411 QgsVectorDataProvider.AddFeatures | QgsVectorDataProvider.DeleteFeatures | 412 QgsVectorDataProvider.ChangeGeometries | 413 QgsVectorDataProvider.ChangeAttributeValues, 414 self._features, 415 self._fields_list, 416 ) 417 self.selected_ids = [] 418 419 def name(self): 420 return self._name 421 422 def id(self): 423 return id(self) 424 425 def geometryType(self): 426 return self._geom_type 427 428 def wkbType(self): 429 return self._wkb_type 430 431 def dataProvider(self): 432 return self._provider 433 434 def fields(self): 435 return _FakeFields(self._fields_list) 436 437 def crs(self): 438 return getattr(self, '_crs', _FakeCrs()) 439 440 def getFeatures(self): 441 return iter(self._features) 442 443 def selectByIds(self, ids): 444 self.selected_ids = list(ids) 445 446 def removeSelection(self): 447 self.selected_ids = [] 448 449 def updateExtents(self): 450 pass 451 452 def triggerRepaint(self): 453 pass 454 455 def updateFields(self): 456 pass 457 458 459 class QgsProject: 460 _instance = None 461 462 def __init__(self): 463 self._layers = {} 464 465 @classmethod 466 def instance(cls): 467 if cls._instance is None: 468 cls._instance = cls() 469 return cls._instance 470 471 def mapLayer(self, layer_id): 472 return self._layers.get(layer_id) 473 474 def addMapLayer(self, layer): 475 self._layers[layer.id()] = layer 476 return layer 477 478 479 # ── qgis.gui stubs ──────────────────────────────────────────────────────── 480 481 class QgsPlotTool: 482 def __init__(self, canvas, name): 483 self._canvas = canvas 484 self._name = name 485 486 def plotReleaseEvent(self, event): 487 pass 488 489 490 class QgsPlotToolPan: 491 def __init__(self, canvas): 492 self._canvas = canvas 493 494 495 class QgsElevationProfileCanvas: 496 def __init__(self): 497 self.refresh_count = 0 498 self._tool = None 499 self.destroyed = _FakeSignal() 500 501 def parentWidget(self): 502 return getattr(self, '_parent', None) 503 504 def setTool(self, tool): 505 self._tool = tool 506 507 def refresh(self): 508 self.refresh_count += 1 509 510 def canvasPointToPlotPoint(self, pt): 511 return _FakeProfilePoint() 512 513 def plotPointToCanvasPoint(self, point): 514 return QgsPointXY(point.distance(), point.elevation()) 515 516 def profileCurve(self): 517 return _FakeCurve() 518 519 def crs(self): 520 return _FakeCrs() 521 522 523 class _FakeProfilePoint: 524 def __init__(self, distance=10.0, elevation=5.0): 525 self._distance = distance 526 self._elevation = elevation 527 528 def distance(self): 529 return self._distance 530 531 def elevation(self): 532 return self._elevation 533 534 535 class _FakeCurve: 536 def clone(self): 537 return self 538 539 540 class _FakeCrs: 541 def __init__(self, authid='EPSG:4326'): 542 self._authid = authid 543 544 def isValid(self): 545 return True 546 547 def authid(self): 548 return self._authid 549 550 def __eq__(self, other): 551 if not isinstance(other, _FakeCrs): 552 return NotImplemented 553 return self._authid == other._authid 554 555 def __hash__(self): 556 return hash(self._authid) 557 558 559 # ── installer ───────────────────────────────────────────────────────────── 560 561 def install(): 562 """Register all fake qgis sub-modules into sys.modules.""" 563 qgis = ModuleType('qgis') 564 qgis_core = ModuleType('qgis.core') 565 qgis_gui = ModuleType('qgis.gui') 566 qgis_pyqt = ModuleType('qgis.PyQt') 567 qgis_pyqt_qtcore = ModuleType('qgis.PyQt.QtCore') 568 qgis_pyqt_qtgui = ModuleType('qgis.PyQt.QtGui') 569 qgis_pyqt_qtwidgets = ModuleType('qgis.PyQt.QtWidgets') 570 571 for name in [ 572 'Qgis', 'QgsApplication', 'QgsCoordinateTransform', 'QgsFeature', 'QgsField', 573 'QgsGeometry', 'QgsMessageLog', 'QgsPoint', 'QgsPointXY', 'QgsProfilePoint', 574 'QgsProject', 'QgsVectorDataProvider', 'QgsVectorLayer', 'QgsWkbTypes', 575 ]: 576 setattr(qgis_core, name, globals()[name]) 577 578 for name in ['QgsElevationProfileCanvas', 'QgsPlotTool', 'QgsPlotToolPan']: 579 setattr(qgis_gui, name, globals()[name]) 580 581 qgis_pyqt_qtcore.Qt = Qt 582 qgis_pyqt_qtcore.QPointF = QPointF 583 qgis_pyqt_qtcore.QEvent = QEvent 584 qgis_pyqt_qtcore.QObject = QObject 585 qgis_pyqt_qtcore.QTimer = QTimer 586 qgis_pyqt_qtcore.QMetaType = QMetaType 587 qgis_pyqt_qtgui.QIcon = QIcon 588 qgis_pyqt_qtwidgets.QAction = QAction 589 qgis_pyqt_qtwidgets.QToolBar = QToolBar 590 591 sys.modules['qgis'] = qgis 592 sys.modules['qgis.core'] = qgis_core 593 sys.modules['qgis.gui'] = qgis_gui 594 sys.modules['qgis.PyQt'] = qgis_pyqt 595 sys.modules['qgis.PyQt.QtCore'] = qgis_pyqt_qtcore 596 sys.modules['qgis.PyQt.QtGui'] = qgis_pyqt_qtgui 597 sys.modules['qgis.PyQt.QtWidgets'] = qgis_pyqt_qtwidgets 598 599 qgis.core = qgis_core 600 qgis.gui = qgis_gui 601 qgis.PyQt = qgis_pyqt 602 qgis_pyqt.QtCore = qgis_pyqt_qtcore 603 qgis_pyqt.QtGui = qgis_pyqt_qtgui 604 qgis_pyqt.QtWidgets = qgis_pyqt_qtwidgets 605 606 # Reset project singleton for each test run. 607 QgsProject._instance = None