2222from collections .abc import Callable , Generator , Iterator , Mapping , Sequence
2323from functools import wraps
2424from pathlib import Path
25- from typing import TYPE_CHECKING , Any , Union
25+ from typing import TYPE_CHECKING , Any
2626from unittest .mock import MagicMock
2727
2828from osgeo import gdal
3131 QgsCoordinateReferenceSystem ,
3232 QgsCoordinateTransform ,
3333 QgsFeature ,
34- QgsGeometry ,
3534 QgsField ,
35+ QgsGeometry ,
3636 QgsLayerTree ,
3737 QgsLayerTreeGroup ,
3838 QgsLayerTreeLayer ,
@@ -355,10 +355,8 @@ def run_task(
355355 if status in done_statuses or task .isCanceled ():
356356 break
357357 # finalize hook so any cleanup logic runs on the main thread
358- try :
358+ with contextlib . suppress ( Exception ) :
359359 task .finished (result )
360- except Exception :
361- pass
362360 return bool (result )
363361
364362
@@ -400,16 +398,20 @@ def __enter__(self) -> "_SignalWaiter":
400398 self ._signal .connect (self ._on_signal )
401399 return self
402400
403- def __exit__ (self , exc_type , exc , tb ) -> None :
401+ def __exit__ (
402+ self ,
403+ exc_type : type [BaseException ] | None ,
404+ exc : BaseException | None ,
405+ tb : object ,
406+ ) -> None :
404407 try :
405408 if exc_type is None and not self .triggered :
406409 QTimer .singleShot (self ._timeout_ms , self ._on_timeout )
407410 self ._loop .exec ()
408411 finally :
409- try :
412+ with contextlib .suppress (TypeError ):
413+ # already disconnected
410414 self ._signal .disconnect (self ._on_signal )
411- except TypeError :
412- pass # already disconnected
413415
414416
415417def wait_signal (
@@ -445,11 +447,8 @@ def wait_signal(
445447# ---------------------------------------------------------------------------
446448
447449
448- class MessageLogEntry (
449- # Using tuple[str, str, int] via a lightweight namedtuple-ish class
450- # keeps the API usable without an extra dependency on dataclasses.
451- ):
452- __slots__ = ("message" , "tag" , "level" )
450+ class MessageLogEntry :
451+ __slots__ = ("level" , "message" , "tag" )
453452
454453 def __init__ (self , message : str , tag : str , level : int ) -> None :
455454 self .message = message
@@ -463,7 +462,9 @@ def __repr__(self) -> str: # pragma: no cover - debug aid
463462 Qgis .Critical : "Critical" ,
464463 Qgis .Success : "Success" ,
465464 }.get (self .level , str (self .level ))
466- return f"MessageLogEntry({ lvl_name } , tag={ self .tag !r} , message={ self .message !r} )"
465+ return (
466+ f"MessageLogEntry({ lvl_name } , tag={ self .tag !r} , message={ self .message !r} )"
467+ )
467468
468469 def __iter__ (self ) -> Iterator [Any ]:
469470 # Allow tuple-style unpacking: message, tag, level = entry
@@ -497,22 +498,18 @@ def _wrap_log_message(
497498 self .entries .append (MessageLogEntry (message , tag , level ))
498499 # Delegate to the original so the real logger still does its thing.
499500 if self ._original is not None :
500- try :
501+ with contextlib .suppress (
502+ Exception
503+ ): # pragma: no cover - never mask test state
501504 self ._original (message , tag , level )
502- except Exception : # pragma: no cover - never mask test state
503- pass
504505
505506 def connect (self ) -> None :
506- from qgis .core import QgsMessageLog # noqa: PLC0415 - avoid top-level hit
507-
508507 if self ._original is not None :
509508 return # already connected
510509 self ._original = QgsMessageLog .logMessage
511510 QgsMessageLog .logMessage = staticmethod (self ._wrap_log_message )
512511
513512 def disconnect (self ) -> None :
514- from qgis .core import QgsMessageLog # noqa: PLC0415
515-
516513 if self ._original is None :
517514 return
518515 QgsMessageLog .logMessage = staticmethod (self ._original )
@@ -558,7 +555,7 @@ def clear(self) -> None:
558555 self .entries .clear ()
559556
560557
561- def _get_qgs_application ():
558+ def _get_qgs_application () -> type :
562559 """Internal helper -- avoids a top-level import cycle."""
563560 from qgis .core import QgsApplication as _QgsApplication # noqa: PLC0415
564561
@@ -589,16 +586,61 @@ def _infer_geometry_kind(sample: str) -> str:
589586 return _GEOMETRY_KIND_BY_WKT_PREFIX [prefix ]
590587
591588
589+ # Field types are kept as QVariant constants so pytest-qgis stays compatible
590+ # with both QGIS 3 (Qt5/QVariant) and QGIS 4 (Qt6/QMetaType — QVariant.* still
591+ # resolves to the same numeric values). See flake8-qgis QGS402.
592592_PYTHON_TYPE_TO_QVARIANT : Mapping [type , Any ] = {
593- int : QVariant .Int ,
594- float : QVariant .Double ,
595- str : QVariant .String ,
596- bool : QVariant .Bool ,
593+ int : QVariant .Int , # noqa: QGS402
594+ float : QVariant .Double , # noqa: QGS402
595+ str : QVariant .String , # noqa: QGS402
596+ bool : QVariant .Bool , # noqa: QGS402
597597}
598598
599599
600+ def _normalise_features (
601+ features : Sequence [str | tuple [str , Mapping [str , Any ]]],
602+ ) -> list [tuple [str , dict [str , Any ]]]:
603+ normalised : list [tuple [str , dict [str , Any ]]] = []
604+ for item in features :
605+ if isinstance (item , str ):
606+ normalised .append ((item , {}))
607+ else :
608+ wkt , attrs = item
609+ normalised .append ((wkt , dict (attrs )))
610+ return normalised
611+
612+
613+ def _build_qgs_fields (fields : Mapping [str , type ]) -> list [QgsField ]:
614+ qgs_fields : list [QgsField ] = []
615+ for fname , fpy_type in fields .items ():
616+ qvariant = _PYTHON_TYPE_TO_QVARIANT .get (fpy_type )
617+ if qvariant is None :
618+ raise TypeError (
619+ f"Field { fname !r} : unsupported type { fpy_type !r} . "
620+ f"Supported: { sorted (t .__name__ for t in _PYTHON_TYPE_TO_QVARIANT )} "
621+ )
622+ qgs_fields .append (QgsField (fname , qvariant ))
623+ return qgs_fields
624+
625+
626+ def _make_feature (
627+ layer : QgsVectorLayer ,
628+ wkt : str ,
629+ attrs : Mapping [str , Any ],
630+ field_names : Sequence [str ],
631+ ) -> QgsFeature :
632+ geom = QgsGeometry .fromWkt (wkt )
633+ if geom .isEmpty ():
634+ raise ValueError (f"Could not parse WKT: { wkt !r} " )
635+ feat = QgsFeature (layer .fields ())
636+ feat .setGeometry (geom )
637+ for fname in field_names :
638+ feat .setAttribute (fname , attrs .get (fname ))
639+ return feat
640+
641+
600642def make_memory_layer (
601- features : Sequence [Union [ str , tuple [str , Mapping [str , Any ] ]]],
643+ features : Sequence [str | tuple [str , Mapping [str , Any ]]],
602644 * ,
603645 fields : Mapping [str , type ] | None = None ,
604646 crs : str = "EPSG:4326" ,
@@ -636,57 +678,29 @@ def make_memory_layer(
636678 if not features :
637679 raise ValueError ("features must be a non-empty sequence" )
638680
639- # Normalise input to (wkt, attrs) pairs and infer schema.
640- normalised : list [tuple [str , dict [str , Any ]]] = []
641- for item in features :
642- if isinstance (item , str ):
643- normalised .append ((item , {}))
644- else :
645- wkt , attrs = item
646- normalised .append ((wkt , dict (attrs )))
647-
681+ normalised = _normalise_features (features )
648682 if geometry_kind is None :
649683 geometry_kind = _infer_geometry_kind (normalised [0 ][0 ])
650-
651684 if fields is None :
652- first_attrs = normalised [0 ][1 ]
653- fields = {k : type (v ) for k , v in first_attrs .items ()}
685+ fields = {k : type (v ) for k , v in normalised [0 ][1 ].items ()}
654686
655- # Create the layer.
656- layer = QgsVectorLayer (
657- f"{ geometry_kind } ?crs={ crs } " , name , "memory"
658- )
687+ layer = QgsVectorLayer (f"{ geometry_kind } ?crs={ crs } " , name , "memory" )
659688 if not layer .isValid ():
660689 raise RuntimeError (
661690 f"Failed to construct memory layer (geom={ geometry_kind } , crs={ crs } )"
662691 )
663692 pr = layer .dataProvider ()
664693
665- # Schema.
666- qgs_fields : list [QgsField ] = []
667- for fname , fpy_type in fields .items ():
668- qvariant = _PYTHON_TYPE_TO_QVARIANT .get (fpy_type )
669- if qvariant is None :
670- raise TypeError (
671- f"Field { fname !r} : unsupported type { fpy_type !r} . "
672- f"Supported: { sorted (t .__name__ for t in _PYTHON_TYPE_TO_QVARIANT )} "
673- )
674- qgs_fields .append (QgsField (fname , qvariant ))
675- if qgs_fields :
676- pr .addAttributes (qgs_fields )
677- layer .updateFields ()
694+ qgs_fields = _build_qgs_fields (fields )
695+ if qgs_fields and not pr .addAttributes (qgs_fields ):
696+ raise RuntimeError (f"Failed to add attributes to memory layer: { qgs_fields !r} " )
697+ layer .updateFields ()
678698
679- # Features.
680699 field_names = list (fields .keys ())
681700 for wkt , attrs in normalised :
682- geom = QgsGeometry .fromWkt (wkt )
683- if geom .isEmpty ():
684- raise ValueError (f"Could not parse WKT: { wkt !r} " )
685- feat = QgsFeature (layer .fields ())
686- feat .setGeometry (geom )
687- for fname in field_names :
688- feat .setAttribute (fname , attrs .get (fname ))
689- pr .addFeature (feat )
701+ feat = _make_feature (layer , wkt , attrs , field_names )
702+ if not pr .addFeature (feat ):
703+ raise RuntimeError (f"Failed to add feature to memory layer: { wkt !r} " )
690704
691705 layer .updateExtents ()
692706 return layer
0 commit comments