@@ -2744,3 +2744,216 @@ And if we want less:
27442744
27452745 In this case, the commands don't print anything to the console, since nothing
27462746at ``WARNING `` level or above is logged by them.
2747+
2748+ .. _qt-gui :
2749+
2750+ A Qt GUI for logging
2751+ --------------------
2752+
2753+ A question that comes up from time to time is about how to log to a GUI
2754+ application. The `Qt <https://www.qt.io/ >`_ framework is a popular
2755+ cross-platform UI framework with Python bindings using `PySide2
2756+ <https://pypi.org/project/PySide2/> `_ or `PyQt5
2757+ <https://pypi.org/project/PyQt5/> `_ libraries.
2758+
2759+ The following example shows how to log to a Qt GUI. This introduces a simple
2760+ ``QtHandler `` class which takes a callable, which should be a slot in the main
2761+ thread that does GUI updates. A worker thread is also created to show how you
2762+ can log to the GUI from both the UI itself (via a button for manual logging)
2763+ as well as a worker thread doing work in the background (here, just random
2764+ short delays).
2765+
2766+ The worker thread is implemented using Qt's ``QThread `` class rather than the
2767+ :mod: `threading ` module, as there are circumstances where one has to use
2768+ ``QThread ``, which offers better integration with other ``Qt `` components.
2769+
2770+ The code should work with recent releases of either ``PySide2 `` or ``PyQt5 ``.
2771+ You should be able to adapt the approach to earlier versions of Qt. Please
2772+ refer to the comments in the code for more detailed information.
2773+
2774+ .. code-block :: python3
2775+
2776+ import datetime
2777+ import logging
2778+ import random
2779+ import sys
2780+ import time
2781+
2782+ # Deal with minor differences between PySide2 and PyQt5
2783+ try:
2784+ from PySide2 import QtCore, QtGui, QtWidgets
2785+ Signal = QtCore.Signal
2786+ Slot = QtCore.Slot
2787+ except ImportError:
2788+ from PyQt5 import QtCore, QtGui, QtWidgets
2789+ Signal = QtCore.pyqtSignal
2790+ Slot = QtCore.pyqtSlot
2791+
2792+ logger = logging.getLogger(__name__)
2793+
2794+ #
2795+ # Signals need to be contained in a QObject or subclass in order to be correctly
2796+ # initialized.
2797+ #
2798+ class Signaller(QtCore.QObject):
2799+ signal = Signal(str)
2800+
2801+ #
2802+ # Output to a Qt GUI is only supposed to happen on the main thread. So, this
2803+ # handler is designed to take a slot function which is set up to run in the main
2804+ # thread. In this example, the function takes a single argument which is a
2805+ # formatted log message. You can attach a formatter instance which formats a
2806+ # LogRecord however you like, or change the slot function to take some other
2807+ # value derived from the LogRecord.
2808+ #
2809+ # You specify the slot function to do whatever GUI updates you want. The handler
2810+ # doesn't know or care about specific UI elements.
2811+ #
2812+ class QtHandler(logging.Handler):
2813+ def __init__(self, slotfunc, *args, **kwargs):
2814+ super(QtHandler, self).__init__(*args, **kwargs)
2815+ self.signaller = Signaller()
2816+ self.signaller.signal.connect(slotfunc)
2817+
2818+ def emit(self, record):
2819+ s = self.format(record)
2820+ self.signaller.signal.emit(s)
2821+
2822+ #
2823+ # This example uses QThreads, which means that the threads at the Python level
2824+ # are named something like "Dummy-1". The function below gets the Qt name of the
2825+ # current thread.
2826+ #
2827+ def ctname():
2828+ return QtCore.QThread.currentThread().objectName()
2829+
2830+ #
2831+ # This worker class represents work that is done in a thread separate to the
2832+ # main thread. The way the thread is kicked off to do work is via a button press
2833+ # that connects to a slot in the worker.
2834+ #
2835+ # Because the default threadName value in the LogRecord isn't much use, we add
2836+ # a qThreadName which contains the QThread name as computed above, and pass that
2837+ # value in an "extra" dictionary which is used to update the LogRecord with the
2838+ # QThread name.
2839+ #
2840+ # This example worker just outputs messages sequentially, interspersed with
2841+ # random delays of the order of a few seconds.
2842+ #
2843+ class Worker(QtCore.QObject):
2844+ @Slot()
2845+ def start(self):
2846+ extra = {'qThreadName': ctname() }
2847+ logger.debug('Started work', extra=extra)
2848+ i = 1
2849+ # Let the thread run until interrupted. This allows reasonably clean
2850+ # thread termination.
2851+ while not QtCore.QThread.currentThread().isInterruptionRequested():
2852+ delay = 0.5 + random.random() * 2
2853+ time.sleep(delay)
2854+ logger.debug('Message after delay of %3.1f: %d', delay, i, extra=extra)
2855+ i += 1
2856+
2857+ #
2858+ # Implement a simple UI for this cookbook example. This contains:
2859+ #
2860+ # * A read-only text edit window which holds formatted log messages
2861+ # * A button to start work and log stuff in a separate thread
2862+ # * A button to log something from the main thread
2863+ # * A button to clear the log window
2864+ #
2865+ class Window(QtWidgets.QWidget):
2866+
2867+ def __init__(self, app):
2868+ super(Window, self).__init__()
2869+ self.app = app
2870+ self.textedit = te = QtWidgets.QTextEdit(self)
2871+ # Set whatever the default monospace font is for the platform
2872+ f = QtGui.QFont('nosuchfont')
2873+ f.setStyleHint(f.Monospace)
2874+ te.setFont(f)
2875+ te.setReadOnly(True)
2876+ PB = QtWidgets.QPushButton
2877+ self.work_button = PB('Start background work', self)
2878+ self.log_button = PB('Log a message at a random level', self)
2879+ self.clear_button = PB('Clear log window', self)
2880+ self.handler = h = QtHandler(self.update_status)
2881+ # Remember to use qThreadName rather than threadName in the format string.
2882+ fs = '%(asctime)s %(qThreadName)-12s %(levelname)-8s %(message)s'
2883+ formatter = logging.Formatter(f)
2884+ h.setFormatter(formatter)
2885+ logger.addHandler(h)
2886+ # Set up to terminate the QThread when we exit
2887+ app.aboutToQuit.connect(self.force_quit)
2888+
2889+ # Lay out all the widgets
2890+ layout = QtWidgets.QVBoxLayout(self)
2891+ layout.addWidget(te)
2892+ layout.addWidget(self.work_button)
2893+ layout.addWidget(self.log_button)
2894+ layout.addWidget(self.clear_button)
2895+ self.setFixedSize(900, 400)
2896+
2897+ # Connect the non-worker slots and signals
2898+ self.log_button.clicked.connect(self.manual_update)
2899+ self.clear_button.clicked.connect(self.clear_display)
2900+
2901+ # Start a new worker thread and connect the slots for the worker
2902+ self.start_thread()
2903+ self.work_button.clicked.connect(self.worker.start)
2904+ # Once started, the button should be disabled
2905+ self.work_button.clicked.connect(lambda : self.work_button.setEnabled(False))
2906+
2907+ def start_thread(self):
2908+ self.worker = Worker()
2909+ self.worker_thread = QtCore.QThread()
2910+ self.worker.setObjectName('Worker')
2911+ self.worker_thread.setObjectName('WorkerThread') # for qThreadName
2912+ self.worker.moveToThread(self.worker_thread)
2913+ # This will start an event loop in the worker thread
2914+ self.worker_thread.start()
2915+
2916+ def kill_thread(self):
2917+ # Just tell the worker to stop, then tell it to quit and wait for that
2918+ # to happen
2919+ self.worker_thread.requestInterruption()
2920+ if self.worker_thread.isRunning():
2921+ self.worker_thread.quit()
2922+ self.worker_thread.wait()
2923+ else:
2924+ print('worker has already exited.')
2925+
2926+ def force_quit(self):
2927+ # For use when the window is closed
2928+ if self.worker_thread.isRunning():
2929+ self.kill_thread()
2930+
2931+ # The functions below update the UI and run in the main thread because
2932+ # that's where the slots are set up
2933+
2934+ @Slot(str)
2935+ def update_status(self, status):
2936+ self.textedit.append(status)
2937+
2938+ @Slot()
2939+ def manual_update(self):
2940+ levels = (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR,
2941+ logging.CRITICAL)
2942+ level = random.choice(levels)
2943+ extra = {'qThreadName': ctname() }
2944+ logger.log(level, 'Manually logged!', extra=extra)
2945+
2946+ @Slot()
2947+ def clear_display(self):
2948+ self.textedit.clear()
2949+
2950+ def main():
2951+ QtCore.QThread.currentThread().setObjectName('MainThread')
2952+ logging.getLogger().setLevel(logging.DEBUG)
2953+ app = QtWidgets.QApplication(sys.argv)
2954+ example = Window(app)
2955+ example.show()
2956+ sys.exit(app.exec_())
2957+
2958+ if __name__=='__main__':
2959+ main()
0 commit comments