Search code examples
pythonqgis

Modify QGIS layer every/after ~5 seconds (without blocking the main thread)


I wrote a Python script for QGIS 3.36.2 (uses Python 3.12.3) that does the following:

  1. Create a layer
  2. Start an HTTP GET request to fetch new coordinates (runs asynchronously by default)
  3. Use these coordinates to draw a marker on the layer (the old marker is removed first, has to run on the main thread afaik)

Step 1 only happens once. 2. + 3. should run indefinitely but stop if there's an error or if the user stops the script. For testing I only want to run it e.g. 10 times.

What I've found/tried so far:

  • time.sleep() (as suggested here) freezes QGIS completely.
  • sched scheduler (see code below) also blocks the main thread and freezes QGIS.
  • threading.Timer would start a new thread every time (and you wouldn't be able to stop the loop), so the answer advises against using it - untested because of that.
  • I can't use Tkinter because QGIS' python doesn't support it.
  • asyncio (as suggested here) doesn't seem to be fully supported in this QGIS version either (lots of errors when trying to run this example but it's working fine in the Python 3.9 console) and it's also kind of blocking because it uses coroutines (see this question; you can yield).

How do I repeat steps 2 and 3 multiple times if there's no error, e.g. 5 seconds after the last iteration finished, without blocking the GUI (especially the map viewer) with some type of sleep and preferably without using any extra libraries?

My code:

#imports here
class ArrowDrawerClass:
    layer = None
    dataprovider = None
    feature = None
    repeat = True
    url = "someURL"
    repeatCounter = 0
    myscheduler = sched.scheduler(time.time,time.sleep)
    
    def __init__(self):
        self.createNewLayer()
    
    def createNewLayer(self):
        layername = "ArrowLayer"
        self.layer =  QgsVectorLayer('Point', layername, "memory")
        self.dataprovider = self.layer.dataProvider()
        self.feature = QgsFeature()
        #Set symbol, color,... of layer here
        QgsProject.instance().addMapLayers([self.layer])

    def doRequest(self):
        request = QNetworkRequest(QUrl(self.url))
        request.setTransferTimeout(10000) #10s
        self.manager = QNetworkAccessManager()
        self.manager.finished.connect(self.handleResponse)
        self.manager.get(request)

    def handleResponse(self, reply):
        err = reply.error()

        if err == QtNetwork.QNetworkReply.NetworkError.NoError:
            bytes = reply.readAll()
            replytext = str(bytes, 'utf-8').strip()
            #extract coordinates here ...
            self.drawArrow(x,y)
        else:
            self.displayError(str(err),reply.errorString())

    def drawArrow(self,x,y):
        self.layer.dataProvider().truncate() #removes old marker
        point1 = QgsPointXY(x,y)
        self.feature.setGeometry(QgsGeometry.fromPointXY(point1))
        self.dataprovider.addFeatures([self.feature])
        self.layer.updateExtents()
        self.layer.triggerRepaint()
        self.repeatCounter += 1
        self.repeatEverything()

    def displayError(self,code,msg):
        self.repeat = False
        #show error dialog here

    def start(self):
        self.myscheduler.enter(0,0,self.doRequest)
        self.myscheduler.run()

    def repeatEverything(self):
        print("counter:",self.repeatCounter)
        if self.repeat and self.repeatCounter<10:
            print("repeat")
            self.myscheduler.enter(5,0,self.test) #TODO: Call "self.doRequest()" instead
            self.myscheduler.run()
        else:
            print("don't repeat!")

    def test(self):
        print("test!")

adc = ArrowDrawerClass()
adc.start()

Solution

  • I managed to accomplish this with a "single shot" (only triggers once) QTimer:

    from PyQt5.QtCore import QTimer
    #Other imports here
    
    class ArrowDrawerClass:
        #Declare variables here
    
        def __init__(self):
            self.timer = QTimer()
            self.timer.setSingleShot(True)
            self.timer.timeout.connect(self.doRequest)
            self.createNewLayer()
        
        #def createNewLayer(self): #No changes
        #def doRequest(self): #No changes
        #def handleResponse(self, reply): #No changes
        
        def drawArrow():
            #draw arrow here
            self.repeatCounter += 1
            self.repeatEverything()
        
        def displayError(self,code,msg):
            self.stopTimer()
            self.repeat = False
            #show error dialog here
        
        def repeatEverything(self):
            print("counter:",self.repeatCounter)
            #print("Main Thread:",(isinstance(threading.current_thread(), threading._MainThread)))
            if self.repeat and self.repeatCounter<10:
                self.startTimer()
            else:
                self.stopTimer()
        
        def startTimer(self):
            if not self.timer.isActive():
                self.timer.start(5000) #5s
        
        def stopTimer(self):
            if self.timer.isActive():
                self.timer.stop()
    
    adc = ArrowDrawerClass()
    adc.doRequest() #Call the function directly, so there's no 5s delay at the beginning
    

    This doesn't block the UI or freeze QGIS (apart from a mini-freeze caused by truncate() but that's a different problem).

    According to the docs, QTimer uses the event loop and the second print in repeatEverything always output True in my tests, so there shouldn't be a need to worry about updating the UI.