I did figure this out. I would say that learning the object oriented techniques needed for pyQt is not so easy. I wanted to post my code in the hopes that it will help others starting out with pyQt. It is heavily commented and covers:
- Creating a logger that reports to a QT text window defined by QT
- Designer Qt signals for communicating with the main loop and worker
thread
- transferring variables when starting a worker thread
- altering logging levels using the GUI
- having the worker thread update a progress bar
- Running under Spyder without causing the kernal to die all the time
This answer is maybe longer than generally accepted, but having a template like this would have saved me an immense amount of frustration and internet searching, so I will present it anyway. It is more or less my application template going forward. There may still be bugs in the code, and I am new at this, so any suggestions or improvements would be welcome.
The code copies and modifies much help from the stackoverflow community. I am very grateful for all your help.
Template GUI code:
# Template GUI application using
# pyqt5 and Qtdesigner
# by "BikeClubVest"
# 3 June 2022
import sys
import logging
from time import sleep
from PyQt5 import (
QtCore, # Core routines
QtGui, # Not sure
uic, # contains translator for .ui files
QtWidgets # contains all widget descriptors
)
from PyQt5.QtCore import (
Qt,
QObject,
QThread,
pyqtSignal
)
# shortcut declarations to make programming simpler
Signal = QtCore.pyqtSignal
Slot = QtCore.pyqtSlot
from PyQt5.QtWidgets import (
QApplication,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
QPlainTextEdit,
)
# This points to the creator XML file
#qtCreatorFile = r'C:\(your path here)\logging_display2.ui'
# use ".\(filename)" to point to the same directory as the Python code
qtCreatorFile = r'.\logging_display4.ui'
# This activates a translator that converts the .ui file
# into a Class with name "Ui_My_App_Window" that contains
# all the pyQt5 descriptors for the designed GUI. You don't
# get to see the code here, but it gets loaded into memory
Ui_My_App_Window, QtBaseClass = uic.loadUiType(qtCreatorFile)
# Calls the basic config routine to initialize default config settings.
# Enter desired values for the default logger here.
# If this is not the first call to basicConfig,
# nothing will be changed.
logging.basicConfig()
# identifies the current logger
logger = logging.getLogger(__name__)
# Sets the logging level for the default logger
# Can be: DEBUG, INFO, WARNING, ERROR, or CRITICAL, by default
#logger.setLevel(logging.INFO)
logger.setLevel(logging.DEBUG)
#
# Signals need to be contained in a QObject or subclass in order to be correctly
# initialized.
#
class Signaller(QtCore.QObject):
# creates a signal named "log event" with the type as shown
log_event = Signal(str, logging.LogRecord)
#
# Output to a Qt GUI is only supposed to happen on the main thread. So, this
# handler is designed to take a slot function which is set up to run in the main
# thread. In this example, the function takes a string argument which is a
# formatted log message, and the log record which generated it. The formatted
# string is just a convenience - you could format a string for output any way
# you like in the slot function itself.
#
# You specify the slot function to do whatever GUI updates you want. The handler
# doesn't know or care about specific UI elements.
#
class QtHandler(QObject, logging.Handler):
def __init__(self, slotfunc, *args, **kwargs):
super(QtHandler, self).__init__(*args, **kwargs)
# Get available signals from "Signaller" class
self.signaller = Signaller()
# Connect the specific signal named "log_event"
# to a function designated by the caller
self.signaller.log_event.connect(slotfunc)
# When a new log event happens, send the event
# out via the named signal, in this case "log_event"
def emit(self, record):
s = self.format(record)
self.signaller.log_event.emit(s, record)
#
# This example uses QThreads, which means that the threads at the Python level
# are named something like "Dummy-1". The function below gets the Qt name of the
# current thread.
#
def ctname():
return QtCore.QThread.currentThread().objectName()
# Create a worker class. In general, this is where your
# desired custom code will go for execution outside the main
# event handling loop.
class Worker(QObject):
# The initialization function is where the data from the event
# loop (My_Application) gets passed to the worker function.
# The worker can reference the data as self.data_passed
def __init__(self, data_passed = [], parent=None):
QThread.__init__(self, parent)
self.data_passed = data_passed
# with the proper initialization, these signals
# Could be added to the Signaller class, above
finished = Signal()
progress = Signal(int)
progress_report = Signal(list)
# This is the definition of the task the worker will
# perform. You can have more than one task for work.
# Other workers will be defs within this class
# with different names performing different tasks.
def worker_task_name(self):
try:
extra = {'qThreadName': QtCore.QThread.currentThread().objectName() }
logger.log(logging.INFO, 'Started work', extra=extra)
'''
worker code goes here!
'''
x=2
# send a message to the logger
logger.log(logging.DEBUG, 'test 0: ' + self.data_passed[0], extra=extra)
# emit data on the "progress" signal
self.progress.emit(0)
# emit data on the "progress_report" signals=
self.progress_report.emit([20, self.data_passed[0]])
sleep(x)
logger.log(logging.INFO, 'test 1: ' + self.data_passed[1], extra=extra)
self.progress.emit(1)
self.progress_report.emit([40, self.data_passed[1]])
sleep(x)
logger.log(logging.WARNING, 'test 2: ' + self.data_passed[2], extra=extra)
self.progress.emit(2)
self.progress_report.emit([60, self.data_passed[2]])
sleep(x)
logger.log(logging.ERROR, f'test 3: ' + self.data_passed[3], extra=extra)
self.progress.emit(3)
self.progress_report.emit([80, self.data_passed[3]])
sleep(x)
logger.log(logging.CRITICAL, f'test 4: ' + self.data_passed[4], extra=extra)
self.progress.emit(4)
self.progress_report.emit([99, self.data_passed[4]])
sleep(x)
'''
end worker code
'''
# the "try-except" structure catches run-time errors and performs
# an action, in this case reporting the error to the log window
except Exception as e:
logger.log(logging.ERROR, 'Exception = ' + str(e), extra = extra)
# signal main thread that the code is done
self.progress.emit(0)
self.progress_report.emit([0, 'Ready'])
self.finished.emit()
# This is the main application loop
# it references the widget "QMainWindow"
# and the GUI code implemented by
# the uic.loadUiType call, above.
class My_Application(QMainWindow, Ui_My_App_Window):
# Colors are used in the "update status"
# routine, below.
COLORS = {
logging.DEBUG: 'black',
logging.INFO: 'blue',
logging.WARNING: 'brown',
logging.ERROR: 'red',
logging.CRITICAL: 'purple',
}
LOG_LEVELS = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL,
}
# some data for passing to the worker function
passed_data = ['This','is','all','passed','data!']
def __init__(self):
# Run the QMainWindow initialization routine
QMainWindow.__init__(self)
# initialize the Ui
# the name should be "Ui_(object_name_of_top-level_object_in_ui_file)
# This makes the setupUi routine, below, callable
Ui_My_App_Window.__init__(self)
# this runs the setupUi code inside the Ui class that was loaded with
# the call to uic.loadUiType, above. No Ui variables will be
# accessible until the setupUi routine is run.
self.setupUi(self)
# Widgets for the GUI were created with "Qt Designer"
# so there is no need to set up the GUI using code.
# The widgets do need to be connected to the event loop:
self.log_button.clicked.connect(self.manual_update)
self.work_button.clicked.connect(self.start_worker)
self.exit_button.clicked.connect(self.run_exit)
self.logging_level_select.currentTextChanged.connect(self.change_logging_level)
#routine to set up the logger
self.setup_logger()
self.handler.setLevel('INFO')
# a status indicator for the loop
self.statuss = 0
# This routine is called once by the "My_Application"
# class init routine to set up the log handler
def setup_logger(self):
# Set up the logging handler, in this case "QtHandler"
# defined above. Remember to use qThreadName rather
# than threadName in the format string.
# This line connects the handler to "QtHandler" and
# points the handler to the slot function ("update_status")
# for the emit signal. This sends the
# signal to the routine "update_status", defined below.
self.handler = QtHandler(self.update_status)
# fs is a string containing format information
# some options are:
# %(pathname)s Full pathname of the source file where the logging call was issued(if available).
# %(filename)s Filename portion of pathname.
# %(module)s Module (name portion of filename).
# %(funcName)s Name of function containing the logging call.
# %(lineno)d Source line number where the logging call was issued (if available).
# %(asctime)s time of log event
#Trying different formatters, below
#fs = '%(asctime)s %(qThreadName)-12s %(levelname)-8s %(message)s'
fs = '(line %(lineno)d) %(qThreadName)-12s %(levelname)-8s %(message)s'
# formatter calls the logging formatter with the format
# string and takes a return reference for the desired
# format.
formatter = logging.Formatter(fs)
# sets the formatter for "QtHandler"
self.handler.setFormatter(formatter)
# Adds QtHandler to the current logger identified above
# at the very beginning of the program
logger.addHandler(self.handler)
# When data is emitted in the "progress" signal, this routine
# will be called, and the value of the "progress" signal will
# be passed into "n" and the "worker_label" text will be updated
def reportProgress(self, n):
self.statuss = n
self.worker_label.setText(f"Long-Running Step: {n}")
def change_logging_level(self, level):
extra = {'qThreadName': ctname() }
#level = self.logging_level_select.currentText
log_level = self.LOG_LEVELS.get(level, logging.INFO)
message = f'change_logging_level to {level}, {log_level}, handler = {self.handler}'
message = message.replace("<", "_").replace(">", "_")
logger.log(logging.DEBUG, message, extra = extra)
self.handler.setLevel(level)
message = f'now log level = ' + str(self.handler)
message = message.replace("<", "_").replace(">", "_")
logger.log(log_level, message, extra = extra)
# receives data when a log event happens and posts the data
# to the log window
def update_status(self, status, record):
color = self.COLORS.get(record.levelno, 'black')
s = '<pre><font color="%s">%s</font></pre>' % (color, status)
self.log_text.appendHtml(s)
def manual_update(self):
# This routine happens when the "manual_update" button is clicked
# This function uses the formatted message passed in, but also uses
# information from the record to format the message in an appropriate
# color according to its severity (level).
extra = {'qThreadName': ctname() }
#if self.thread.isRunning():
if self.statuss == 0:
level, message = logging.WARNING, 'Thread Stopped ' + str(self.statuss)
else:
try:
if self.thread.isRunning() == True:
level, message = logging.INFO, 'Thread Running ' + str(self.statuss)
else:
level, message = logging.WARNING, 'Thread Stopped ' + str(self.statuss)
except Exception as e:
level, message = logging.ERROR, 'Thread Killed Before Completion: ' + str(e)
self.statuss = 0
logger.log(level, message, extra=extra)
# this routine is executed when a signal is emitted by the worker thread
# a change in the declared signal "progress_rreport" wlll trigger this function.
def report_progress_bar(self, progress_report):
extra = {'qThreadName': ctname() }
logger.log(logging.DEBUG, 'Starting report_progress_bar ', extra = extra)
try:
logger.log(logging.DEBUG, f'progress_report = {progress_report}', extra = extra)
self.load_progress_bar.setValue(progress_report[0])
self.case_currently_loading.setText(progress_report[1])
except Exception as e:
logger.log(logging.ERROR, 'Exception = ' + str(e), extra = extra)
logger.log(logging.DEBUG, 'report_progress_bar finished', extra = extra)
# this starts up the worker thread. First the thread is created, then the
# specific worker is constructed by calling the "Worker" class, and passing the
# required data (in this case, "self.data_passed". then the worker is moved to the
# thread, then the specific function is started, then signals are connected,
# then the thread is started, then other stuff is adjusted.
def start_worker(self):
try:
# Create a QThread object
self.thread = QThread()
# Give the thread a name
self.thread.setObjectName('WorkerThread')
# Create a worker object by calling the "Worker" class defined above
self.worker = Worker(self.passed_data)
# Move worker to the thread just created
self.worker.moveToThread(self.thread)
# Connect signals and slots:
# This connects the thread start troutine to the
# name of the routine defined under the "Worker" class, above
self.thread.started.connect(self.worker.worker_task_name)
# this connects the "finished' signal to three routines
# to end the thread when the worker is finished.
self.worker.finished.connect(self.thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
# this connects the "progress" signal defined in
# in the "Worker" class
self.worker.progress.connect(self.reportProgress)
# this executes the "progress_report" signal to the "" function
# for updating the progress bar in the display
self.worker.progress_report.connect(self.report_progress_bar)
# Start the thread
self.thread.start()
# Final resets
# Disables the "worker_start" button when the
# thread is launched. Leaving the button enabled
# will mean that the task can be re-launched multiple
# times which may or may not be a problem.
self.work_button.setEnabled(False)
# re-enables the "Work Start" button disabled above when
# when the thread has finished
self.thread.finished.connect(lambda: self.work_button.setEnabled(True))
# sets the stepLabel text when the thread has finished
self.thread.finished.connect(lambda: self.stepLabel.setText("Long-Running Step: 0"))
except Exception as e:
logger.log(logging.ERROR, 'Exception = ' + str(e), extra = {'qThreadName': ctname() })
# Quits the event loop and closes the window when the Exit
# button is pressed.
def run_exit(self):
self.close()
def force_quit(self):
pass
# For use when the window is closed
#if self.worker_thread.isRunning():
#self.kill_thread()
######################################################################
# The code below resolves the need to kill the kernal after closing
def main():
# sets the name of the current thread in QThread
QtCore.QThread.currentThread().setObjectName('MainThread')
# If the application is not already runnng, launch it,
# otherwise enter the exitsting instance.
if not QApplication.instance():
app = QApplication(sys.argv)
else:
print('already running')
app = QApplication.instance()
# launch ghd main window
main = My_Application()
# show he main window
main.show()
# put the main window at the top of the desktop
main.raise_()
# for execution from Spyder, disable the next
# line or Spyder will throw an error
#sys.exit(app.exec_())
# returning main to the global coller will avoid
# problems with Spyder kernel terminating when
# the application is launched under Spyder
return main
if __name__ == '__main__':
# creating a dummy variable ("m") to accept the
# object returned from "main" will prevent problems
# with the kernal dying while running under Spyder.
m = main()
Here is the .ui file I created in qtdesigner named "logging_display4.ui".
For those who are new, the .ui files are just xml files that you can open and view in a text editor. To use this file, just copy it into notepad or your favorite text editor and save it as "logging_display4.ui". It can then be opened by qt designer.
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>My_Main_Window</class>
<widget class="QMainWindow" name="My_Main_Window">
<property name="enabled">
<bool>true</bool>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1037</width>
<height>844</height>
</rect>
</property>
<property name="windowTitle">
<string>Logging Example</string>
</property>
<widget class="QWidget" name="centralwidget">
<widget class="QPushButton" name="work_button">
<property name="geometry">
<rect>
<x>30</x>
<y>20</y>
<width>261</width>
<height>81</height>
</rect>
</property>
<property name="font">
<font>
<family>Arial</family>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Start Worker</string>
</property>
</widget>
<widget class="QPushButton" name="log_button">
<property name="geometry">
<rect>
<x>320</x>
<y>20</y>
<width>261</width>
<height>81</height>
</rect>
</property>
<property name="font">
<font>
<family>Arial</family>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Manual Log</string>
</property>
</widget>
<widget class="QPushButton" name="exit_button">
<property name="geometry">
<rect>
<x>740</x>
<y>20</y>
<width>261</width>
<height>81</height>
</rect>
</property>
<property name="font">
<font>
<family>Arial</family>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Exit</string>
</property>
</widget>
<widget class="QLabel" name="worker_label">
<property name="geometry">
<rect>
<x>340</x>
<y>110</y>
<width>661</width>
<height>51</height>
</rect>
</property>
<property name="font">
<font>
<family>Arial</family>
<pointsize>12</pointsize>
</font>
</property>
<property name="accessibleName">
<string>Worker Status</string>
</property>
<property name="layoutDirection">
<enum>Qt::LeftToRight</enum>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::Panel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<property name="lineWidth">
<number>3</number>
</property>
<property name="text">
<string>Worker Running -- Step: 0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
<widget class="QPlainTextEdit" name="log_text">
<property name="geometry">
<rect>
<x>30</x>
<y>200</y>
<width>971</width>
<height>561</height>
</rect>
</property>
<property name="font">
<font>
<family>8514oem</family>
</font>
</property>
<property name="plainText">
<string/>
</property>
</widget>
<widget class="QLabel" name="worker_label_2">
<property name="geometry">
<rect>
<x>30</x>
<y>110</y>
<width>301</width>
<height>51</height>
</rect>
</property>
<property name="font">
<font>
<family>Arial</family>
<pointsize>12</pointsize>
</font>
</property>
<property name="accessibleName">
<string>Worker Status</string>
</property>
<property name="layoutDirection">
<enum>Qt::LeftToRight</enum>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::Panel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<property name="lineWidth">
<number>3</number>
</property>
<property name="text">
<string>Set Log Level:</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
<widget class="QComboBox" name="logging_level_select">
<property name="geometry">
<rect>
<x>200</x>
<y>120</y>
<width>111</width>
<height>31</height>
</rect>
</property>
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="currentIndex">
<number>3</number>
</property>
<item>
<property name="text">
<string>CRITICAL</string>
</property>
</item>
<item>
<property name="text">
<string>ERROR</string>
</property>
</item>
<item>
<property name="text">
<string>WARNING</string>
</property>
</item>
<item>
<property name="text">
<string>INFO</string>
</property>
</item>
<item>
<property name="text">
<string>DEBUG</string>
</property>
</item>
</widget>
<widget class="QLabel" name="case_currently_loading">
<property name="geometry">
<rect>
<x>30</x>
<y>170</y>
<width>301</width>
<height>21</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<family>Arial</family>
<pointsize>8</pointsize>
</font>
</property>
<property name="accessibleName">
<string>Worker Status</string>
</property>
<property name="layoutDirection">
<enum>Qt::LeftToRight</enum>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="text">
<string>Ready</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
<widget class="QProgressBar" name="load_progress_bar">
<property name="geometry">
<rect>
<x>340</x>
<y>170</y>
<width>661</width>
<height>23</height>
</rect>
</property>
<property name="value">
<number>0</number>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="invertedAppearance">
<bool>false</bool>
</property>
</widget>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1037</width>
<height>31</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<resources/>
<connections/>
</ui>