本文给出一个 Python tkinter GUI 程序中调用 shell 脚本的代码示例,在 GUI 的一个文本框中会不断输出脚本的标准输出和标准错误且不会阻塞 GUI 窗体事件响应,同时也会在脚本执行完毕后根据状态(exit code)弹出提示框,如果在脚本运行中关闭窗口,会弹出提示并中断脚本的执行。

这个样例很适合编写一些提供给非技术人员使用的小工具。

代码如下,略长,使用的是 python 3,因为依赖 select 的原因,仅测试过 linux 下的使用,非 Posix 系统不能保证功能正常。代码也适合调用其他 console 程序,而不仅限于 shell 脚本。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import tkinter
from tkinter import messagebox, scrolledtext
import subprocess
import select
import os
import signal

WINDOW_SIZE = "600x400"

# Run Button
btn_upd = None
# Output textbox
txt_out = None
# current script job/process
cur_process = None
# mark if Tk is closing
close_flag = False
# Tk root
root = tkinter.Tk()


def disable_allbtn():
    global close_flag
    global btn_upd
    if close_flag:
        return
    btn_upd.configure(state=tkinter.DISABLED)

def enable_allbtn():
    global close_flag
    global btn_upd
    if close_flag:
        return
    btn_upd.configure(state=tkinter.NORMAL)

def cb_runbash_withoutput(script):
    global cur_process
    global txt_out
    global root
    global close_flag
    # clear output
    txt_out.delete(1.0, tkinter.END)
    # create subprocess
    process = subprocess.Popen(script, shell=True, preexec_fn=os.setsid,
        stderr=subprocess.PIPE, stdout=subprocess.PIPE)
    # read subprocess stderr/stdout in non-blocking
    outpoll = select.poll()
    errpoll = select.poll()
    outpoll.register(process.stdout, select.POLLIN)
    errpoll.register(process.stderr, select.POLLIN)
    # store current subprocess
    cur_process = process
    while True:
        output = ""
        errput = ""
        # poll stdout, timeout 2ms
        if outpoll.poll(2):
            output = process.stdout.readline().decode()
            # insert stdout to txt_out
            if output and not close_flag:
                txt_out.insert(tkinter.INSERT, output)
        # poll stderr, timeout 2ms
        if errpoll.poll(2):
            errput = process.stderr.readline().decode()
            # insert stderr to txt_out
            if errput and not close_flag:
                txt_out.insert(tkinter.INSERT, errput)
        # scroll text if have output
        if (output or errput) and not close_flag:
            txt_out.see(tkinter.END)
        # do eventloop when not closing
        if not close_flag:
            root.update()
        # process end
        if output == "" and process.poll() is not None:
            break
    # get process return code
    code = process.poll()
    # reset current subprocess
    cur_process = None
    return code

# callback 函数
def cb_update():
    global close_flag
    disable_allbtn()
    code = cb_runbash_withoutput("./bash_script_sample.sh")
    # exit when closing
    if close_flag:
        return
    if code != 0:
        messagebox.showerror(title="Error", message="run bash script failed")
    else:
        messagebox.showinfo(title="Info", message="run bash script success")
    enable_allbtn()


def on_closing():
    global cur_process
    global root
    global close_flag
    # if cur_process still running
    if cur_process:
        if messagebox.askokcancel("Quit", "There is task running, do you want to quit?"):
            # kill subprocess
            os.killpg(os.getpgid(cur_process.pid), signal.SIGTERM)
            cur_process = None
        else:
            return
    # mark closing
    close_flag = True
    # close window
    root.destroy()

root.title("Hello world")
root.geometry(WINDOW_SIZE)
# register cloing event callback
root.protocol("WM_DELETE_WINDOW", on_closing)

btn_upd = tkinter.Button(root, text="Update", command = cb_update)
btn_upd.pack(fill=tkinter.X, pady=10)

txt_out = scrolledtext.ScrolledText(root)
txt_out.pack(fill=tkinter.BOTH, expand=True, padx=20, pady=20)

tkinter.mainloop()

核心是使用 subprocess 调用 console 程序,然后在 GUI callback 中不断读取程序的 stdout/stderr,同时不阻塞 GUI 事件循环。