简体   繁体   中英

Python 3, Tkinter and threading a long-running process, OOP style

My little program has a potentially long running process. That's not a problem when doing it from the console, but now I want to add a GUI. Ideally I want to use Tkinter (a) because it's simple, and (b) because it might be easier to implement across platforms. From what I've read and experienced, (almost) all GUIs suffer the same issue anyway.

Through all my reading on the subject of threading and GUI there seem to be two streams. 1 - where the underlying worker process is polling (eg waiting to fetch data), and 2 - where the worker process is doing a lot of work (eg copying files in a for loop). My program falls into the latter.

My code has a "hierarchy" of classes.
The MIGUI class handles the GUI and interacts with the interface class MediaImporter. The MediaImporter class is the interface between the user interface (console or GUI) and the worker classes. The Import class is the long-running worker. It does not know that the interface or GUI classes exist.

The problem: After clicking the Start button, the GUI is blocked, so I can't click the Abort button. It is as if I'm not using threading at all. I suspect the issue is with the way I am starting the threading in startCallback method.

I've also tried the approach of threading the entire MediaImporter class. See the commented-out lines.

import tkinter as tk
from tkinter import ttk
from tkinter import filedialog
import threading
import time


class MIGUI():
    def __init__(self, master):
        self.master = master

        self.mediaImporter = MediaImporter()

        self.startButton = ttk.Button(self.master, text='Start', command=self.startCallback)
        self.startButton.pack()

        self.abortButton = ttk.Button(self.master, text='Abort', command=self.abortCallback)
        self.abortButton.state(['disabled'])
        self.abortButton.pack()

    def startCallback(self):
        print('startCallback')
        self.abortButton.state(['!disabled'])
        self.startButton.state(['disabled'])
        self.abortButton.update()  # forcing the update seems unnecessary
        self.startButton.update()
        #print(self.startButton.state())
        #print(self.abortButton.state())

        self.x = threading.Thread(target=self.mediaImporter.startImport)
        self.x.start()
        self.x.join()

        #self.mediaImporter.startImport()

        self.startButton.state(['!disabled'])
        self.abortButton.state(['disabled'])
        self.abortButton.update()
        self.startButton.update()
        #print(self.startButton.state())
        #print(self.abortButton.state())

    def abortCallback(self):
        print('abortCallback')
        self.mediaImporter.abortImport()
        self.startButton.state(['!disabled'])
        self.abortButton.state(['disabled'])


class MediaImporter():
#class MediaImporter(threading.Thread):
    """ Interface between user (GUI / console) and worker classes """
    def __init__(self):
        #threading.Thread.__init__(self)

        self.Import = Import()
        #other worker classes exist too

    def startImport(self):
        print('mediaImporter - startImport')
        self.Import.start()

    def abortImport(self):
        print('mediaImporter - abortImport')
        self.Import.abort()


class Import():
    """ Worker
        Does not know anything about other non-worker classes or UI.
    """
    def __init__(self):
        self._wantAbort = False

    def start(self):
        print('import - start')
        self._wantAbort = False
        self.doImport()

    def abort(self):
        print('import - abort')
        self._wantAbort = True    

    def doImport(self):
        print('doImport')
        for i in range(0,10):
            #actual code has nested for..loops
            print(i)
            time.sleep(.25)
            if self._wantAbort:
                print('doImport - abort')
                return


def main():
    gui = True
    console = False

    if gui:
        root = tk.Tk()
        app = MIGUI(root)
        root.mainloop()
    if console:
        #do simple console output without tkinter - threads not necessary
        pass

if __name__ == '__main__':
    main()

The reason your GUI is blocked is because you call self.x.join() , which blocks until the doImport function is complete, see the join documentation. Instead I would call join() in your abortCallback() function, since that is what will cause the thread to stop running.

Thank you again XORNAND. The join() was definitely part of the problem. The other part of the problem was that there was no means of the MIGUI class knowing when the long-running process was complete (either because it had run its course, or because it was aborted.) An additional layer of messaging is required between the low-level worker, and the UI layer. I did try to use threading.Event without success, and did consider using Queues.

My solution is to use pubsub. ( https://github.com/schollii/pypubsub ) The worker layer can sendMessage on various topics, and the UI and interface layers can set up Listeners to perform actions with received data.

In my case, the Import.doImport method sends a STATUS message when it is completed. The MIGUI listener can then flip-flop the Start/Abort buttons accordingly.

To make sure the implementation of pubsub was going to work as planned I also set up a tkinter Progressbar. The doImport method sends a PROGESS message with the percent complete. This is reflected in the on-screen Progressbar.

A side note - in my original issue I had to use .update() on the buttons to get them to display. Now that we're not blocking anymore, this is not necessary.

Posting the complete working solution here, showing the pubsub implementation.

import tkinter as tk
from tkinter import ttk
import threading
import time
from pubsub import pub

class MIGUI():
    def __init__(self, master):
        self.master = master
        self.mediaImporter = MediaImporter()
        self.startButton = ttk.Button(self.master, text='Start', command=self.startCallback)
        self.startButton.pack()
        self.abortButton = ttk.Button(self.master, text='Abort', command=self.abortCallback)
        self.abortButton.state(['disabled'])
        self.abortButton.pack()
        self.progress = ttk.Progressbar(self.master, length=300)
        self.progress.pack()
        pub.subscribe(self.statusListener, 'STATUS')
        pub.subscribe(self.progressListener, 'PROGRESS')
    def statusListener(self, status, value):
        print('MIGUI', status, value)
        if status == 'copying' and (value == 'finished' or value == 'aborted'):
            self.startButton.state(['!disabled'])
            self.abortButton.state(['disabled'])
    def progressListener(self, value):
        print('Progress %d' % value)
        self.progress['maximum'] = 100
        self.progress['value'] = value
    def startCallback(self):
        print('startCallback')
        self.abortButton.state(['!disabled'])
        self.startButton.state(['disabled'])
        self.x = threading.Thread(target=self.mediaImporter.startImport)
        self.x.start()
        # original issue had join() here, which was blocking.
    def abortCallback(self):
        print('abortCallback')
        self.mediaImporter.abortImport()

class MediaImporter():
    """ Interface between user (GUI / console) and worker classes """
    def __init__(self):
        self.Import = Import()
        #other worker classes exist too
        pub.subscribe(self.statusListener, 'STATUS')
    def statusListener(self, status, value):
        #perhaps do something
        pass
    def startImport(self):
        self.Import.start()
    def abortImport(self):
        self.Import.abort()

class Import():
    """ Worker
        Does not know anything about other non-worker classes or UI.
        It does use pubsub to publish messages - such as the status and progress.
        The UI and interface classes can subsribe to these messages and perform actions. (see listener methods)
    """
    def __init__(self):
        self._wantAbort = False
    def start(self):
        self._wantAbort = False
        self.doImport()
    def abort(self):
        pub.sendMessage('STATUS', status='abort', value='requested')
        self._wantAbort = True
    def doImport(self):
        self.max = 13
        pub.sendMessage('STATUS', status='copying', value='started')
        for i in range(1,self.max):
            #actual code has nested for..loops
            progress = ((i+1) / self.max * 100.0)
            pub.sendMessage('PROGRESS', value=progress)
            time.sleep(.1)
            if self._wantAbort:
                pub.sendMessage('STATUS', status='copying', value='aborted')
                return
        pub.sendMessage('STATUS', status='copying', value='finished')

def main():
    gui = True
    console = False
    if gui:
        root = tk.Tk()
        app = MIGUI(root)
        root.mainloop()
    if console:
        #do simple console output without tkinter - threads not necessary
        pass
if __name__ == '__main__':
    main()

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM