laxer
laxer

Reputation: 760

PYQT5 Thread Issues with Schedule and timer

I am using PYQT5 to build a GUI and I am using APScheduler to manage the jobs I would like to run. I have the scheduler items and timer items broken into there own classes and then connecting them in the main file.

The issue I am having is once the timer finishes one cycle, I try to add time to the Timer class and start it over for the next count down before the scheduler is supposed to run again. I am getting two errors or warnings and I am not sure how to fix them. They are:

QObject::killTimer: Timers cannot be stopped from another thread
QObject::startTimer: Timers cannot be started from another thread

Once these are thrown the GUI updates but no longer counts down. I will attach the simplest version that I have found that will reproduce the errors. Any help is greatly appreciated and thank you for your time.

Main.py

import sys
from PyQt5 import QtCore
from PyQt5 import QtWidgets

from Timer import Timer
from Schedule import Scheduler

import datetime


class MyMainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
 
        central_widget = QtWidgets.QWidget()
        self.setCentralWidget(central_widget)
        vbox = QtWidgets.QVBoxLayout()
        central_widget.setLayout(vbox)

        self.start_pushButton = QtWidgets.QPushButton()
        self.start_pushButton.setText("Start")
        self.start_pushButton.clicked.connect(self.start_schedule)
        vbox.addWidget(self.start_pushButton)

        self.pages_qsw = QtWidgets.QStackedWidget()
        vbox.addWidget(self.pages_qsw)
        self.time_passed_qll = QtWidgets.QLabel()
        vbox.addWidget(self.time_passed_qll)

        self.my_timer = Timer()
        self.my_timer.get_seconds.connect(self.update_gui)

        self.sch = Scheduler() 
 
    def start_schedule(self):
        self.sch.add(self.hello)
        self.sch.start()
        self.start_my_timer()


    def start_my_timer(self):
        next_run = self.sch.next_occurance().replace(tzinfo=None) # This removes the time zone.
    
        a = datetime.datetime.now()
        difference = next_run - a

        self.my_timer.addSecs(difference.seconds)
        self.my_timer.timer_start()

    def hello(self):
        print("hello world")
        self.start_my_timer()
    
    @QtCore.pyqtSlot(str)
    def update_gui(self,seconds):
        self.time_passed_qll.setText(str(seconds))

app = QtWidgets.QApplication(sys.argv)
main_window = MyMainWindow()
main_window.show()
sys.exit(app.exec_())

Timer.py

from PyQt5.QtCore import QTimer, pyqtSignal
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *

import datetime

class Timer(QTimer):
    get_seconds = pyqtSignal(str)
    
    def __init__(self):
        super().__init__()
        self.time_left = 1
        self.timeout.connect(self.timer_timeout)

    def addSecs(self, secs):
        self.time_left += secs

    def timer_start(self):
        self.start(1000)
        self.update_gui()

    def timer_timeout(self):
        self.time_left -= 1

        if self.time_left <= 0:
            self.stop()

        self.update_gui()

    def update_gui(self):
        self.get_seconds.emit(str(self.time_left))

Schedule.py

from datetime import datetime
from apscheduler.schedulers.qt import QtScheduler   

class Scheduler():
    def __init__(self):
        self.id = 'test_job'
        self.sched = QtScheduler()

    def add(self,job_function):
        self.sched.add_job(job_function, 'cron', day_of_week='mon-fri',hour ='9-18',minute = '2,7,12,17,22,27,32,37,42,47,52,57',second = '5', id=self.id)

    def start(self):
        self.sched.start()

    def next_occurance(self):
        for job in self.sched.get_jobs():
            if job.id == self.id:
                return job.next_run_time  

Upvotes: 1

Views: 2136

Answers (1)

musicamante
musicamante

Reputation: 48231

As the error explains, you cannot start and stop a QTimer from another thread, and since APScheduler works on different threads that the reason of your issue: self.hello is called from the APScheduler thread, not the thread from which your Timer is created.

To access objects created in different threads, you need to use signals and slots in order to let Qt manage communications between different threads.

So, the solution could be to subclass your Scheduler by inheriting from QObject (in order to be able to create signals and connect to them), then use a custom signal each time a job is executed and use that signal to restart the timer.

To achieve that, I use a createJob function which actually runs the job and emits a started signal when the job is started, and a completed when completed.

Unfortunately I cannot test the following code as I'm unable to install APScheduler right now, but the logic should be fine.

Main.py

class MyMainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        # ...
        self.sch = Scheduler()
        self.sch.completed.connect(self.start_my_timer)

    def hello(self):
        print("hello world")
        # no call to self.start_my_timer here!

Schedule.py

from datetime import datetime
from apscheduler.schedulers.qt import QtScheduler
from PyQt5 import QtCore

class Scheduler(QtCore.QObject):
    started = QtCore.pyqtSignal(object)
    completed = QtCore.pyqtSignal(object)
    def __init__(self):
        self.id = 'test_job'
        self.sched = QtScheduler()

    def add(self, job_function, *args, **kwargs):
        self.sched.add_job(self.createJob(job_function), 'cron', 
            day_of_week='mon-fri', hour='9-18',
            minute='2,7,12,17,22,27,32,37,42,47,52,57',
            second='5', id=self.id, *args, **kwargs)

    def createJob(self, job_function):
        def func(*args, **kwargs):
            self.started.emit(job_function)
            job_function(*args, **kwargs)
            self.completed.emit(job_function)
        return func

    def start(self):
        self.sched.start()

    def next_occurance(self):
        for job in self.sched.get_jobs():
            if job.id == self.id:
                return job.next_run_time

Note that I'm emitting the started and completed signals with the job_function argument (which is a reference to the job, self.hello in your case), which might be useful to recognize the job in case you want to react differently with multiple jobs. I also added basic support for positional and keyword arguments.

Also note that I'm only giving a very basic implementation (your function only prints a message). If you need to interact to UI elements in the job function, the same problem with QTimer will rise, as no access to UI elements is allowed from threads outside the main Qt thread.

In that case you'll need to find another way. For example, you could add a job (that is not actually run from the scheduler) and emit a signal with that job as argument, and connect to a function that will actually run that job in the main thread.

Main.py

class MyMainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        # ...
        self.sch = Scheduler()
        self.sch.startJob.connect(self.startJob)

    def startJob(self, job, args, kwargs):
        job(*args, **kwargs)
        self.start_my_timer()

    def hello(self):
        self.someLabel.setText("hello world")

Schedule.py

from datetime import datetime
from apscheduler.schedulers.qt import QtScheduler
from PyQt5 import QtCore

class Scheduler(QtCore.QObject):
    startJob = QtCore.pyqtSignal(object, object, object)
    # ...
    def add(self, job_function, *args, **kwargs):
        self.sched.add_job(self.createJob(job_function, args, kwargs), 'cron', 
            day_of_week='mon-fri', hour='9-18',
            minute='2,7,12,17,22,27,32,37,42,47,52,57',
            second='5', id=self.id)

    def createJob(self, job_function, args, kwargs):
        def func():
            self.starJob.emit(job_function, args, kwargs)
        return func

As said, the code above is untested, you'll need to check for possible bugs (maybe I made some mistake with the wildcard arguments).


Finally, some small suggestions:
  1. There are very few and specific cases for which using pyqtSlot decorator is actually necessary; interestingly enough, using them is often source of problems or unexpected behavior.
  2. It's usually better to leave signal arguments as they are without any conversion, so you shouldn't convert the time to a string for the get_seconds signal; also, QLabel can accept numeric values using setNum() (both for float and int numbers).
  3. Be more careful with spacings (I'm referring to self.sched.add_job): for keyword arguments, spaces should only exist after commas (read more on the Style Guide for Python Code); while it actually doesn't represent an issue, it greatly improves readability.

Upvotes: 5

Related Questions