tkinter 等 GUI 工具包是事件驱动的。为了正常工作,
mainloop 必须能够持续处理键盘和鼠标事件。
当它不处理事件时,它会启动预定的空闲任务。
因此它们的工作方式与正常运行的 Python 脚本完全不同
从上到下。
tkinter 程序在mainloop 中运行。所以只有三个
在开始主循环之前你要做的事情。
- 创建一个带有一些小部件的窗口。
- 创建保存程序状态的对象(变量)。
- 将可以从
mainloop 运行的函数定义为回调 或
空闲任务。
调用回调以响应激活控件(例如单击
一个按钮)。
系统在指定的毫秒数后启动空闲任务
当系统不忙于处理事件时。您可以安排空闲任务
使用Tk.after() 方法。
基本上,回调和空闲任务是你的程序。
为了保持 GUI 响应,回调和空闲任务不应花费太长时间;比如说 50 毫秒。
因此,在单个回调中运行完整的防病毒扫描确实会使 GUI 无响应。
基本上,有三种可能的解决方案。
- 将扫描过程分成小块。第一部分在回调中执行,其余部分在空闲任务中完成。这可能是最简单的解决方案,因为它可以让您在扫描时轻松更新 GUI。
- 使用
multiprocessing 启动一个单独的程序来进行扫描。设置Pipe 以启用 GUI 和扫描程序之间的通信。在 GUI 中使用空闲任务从管道中读取消息并相应地更新 GUI。
- 使用
threading 启动一个单独的线程来进行扫描。关于 tkinter 实际上可以安全地从多个线程调用存在一些混淆;请参阅下面的示例。
哪一个最适合你我很难说。
下面我以tkinter脚本的形式给出1和3的例子来解锁ms-excel文件。
首先是非线程版本:
"""Remove passwords from modern excel 2007+ files (xlsx, xlsm)."""
from types import SimpleNamespace
import os
import re
import shutil
import stat
import sys
import zipfile
from tkinter import filedialog
from tkinter import ttk
from tkinter.font import nametofont
import tkinter as tk
__version__ = "2020.04.20"
def create_widgets(root):
"""Create the window and its widgets.
Arguments:
root: the root window.
Returns:
A SimpleNamespace of widgets
"""
# Set the font.
default_font = nametofont("TkDefaultFont")
default_font.configure(size=12)
root.option_add("*Font", default_font)
# General commands and bindings
root.bind_all('q', do_exit)
root.wm_title('Unlock excel files v' + __version__)
root.columnconfigure(3, weight=1)
root.rowconfigure(5, weight=1)
# A SimpleNamespace is used to save widgets that need to be accessed later.
w = SimpleNamespace()
# First row
ttk.Label(root, text='(1)').grid(row=0, column=0, sticky='ew')
fb = ttk.Button(root, text="Select file", command=do_file)
fb.grid(row=0, column=1, columnspan=2, sticky="w")
w.fb = fb
fn = ttk.Label(root)
fn.grid(row=0, column=3, columnspan=2, sticky="ew")
w.fn = fn
# Second row
ttk.Label(root, text='(2)').grid(row=1, column=0, sticky='ew')
backup = tk.IntVar()
backup.set(0)
w.backup = backup
ttk.Checkbutton(root, text='backup', variable=backup,
command=on_backup).grid(row=1, column=1, sticky='ew')
suffixlabel = ttk.Label(root, text='suffix:')
suffixlabel['state'] = 'disabled'
suffixlabel.grid(row=1, column=2, sticky='ew')
w.suffixlabel = suffixlabel
suffix = tk.StringVar()
suffix.set('-orig')
w.suffix = suffix
se = ttk.Entry(root, justify='left', textvariable=suffix)
se.grid(row=1, column=3, columnspan=1, sticky='w')
se['state'] = 'disabled'
w.suffixentry = se
# Third row
ttk.Label(root, text='(3)').grid(row=2, column=0, sticky='ew')
gobtn = ttk.Button(root, text="Go!", command=do_start)
gobtn['state'] = 'disabled'
gobtn.grid(row=2, column=1, sticky='ew')
w.gobtn = gobtn
# Fourth row
ttk.Label(root, text='(4)').grid(row=3, column=0, sticky='ew')
ttk.Label(root, text='Progress:').grid(row=3, column=1, sticky='w')
# Fifth row
sb = tk.Scrollbar(root, orient="vertical")
status = tk.Listbox(root, width=40, yscrollcommand=sb.set)
status.grid(row=4, rowspan=5, column=1, columnspan=3, sticky="nsew")
w.status = status
sb.grid(row=4, rowspan=5, column=5, sticky="ns")
sb.config(command=status.yview)
# Ninth row
ttk.Button(root, text="Quit", command=do_exit).grid(row=9, column=1, sticky='ew')
# Return the widgets that need to be accessed.
return w
def create_state():
"""Create and initialize the global state."""
state = SimpleNamespace()
state.interval = 10
state.path = ''
state.inzf, state.outzf = None, None
state.infos = None
state.currinfo = None
state.worksheets_unlocked = 0
state.workbook_unlocked = False
state.directory = None
state.remove = None
return state
def statusmsg(text):
"""Append a message to the status listbox, and make sure it is visible."""
widgets.status.insert(tk.END, text)
widgets.status.see(tk.END)
# Step functions to call in the after() method.
def step_open_zipfiles():
path = widgets.fn['text']
state.path = path
statusmsg(f'Opening “{path}”...')
first, last = path.rsplit('.', maxsplit=1)
if widgets.backup.get():
backupname = first + widgets.suffix.get() + '.' + last
else:
backupname = first + '-orig' + '.' + last
state.remove = backupname
shutil.move(path, backupname)
state.inzf = zipfile.ZipFile(backupname, mode="r")
state.outzf = zipfile.ZipFile(
path, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=1
)
root.after(state.interval, step_discover_internal_files)
def step_discover_internal_files():
statusmsg(f'Reading “{state.path}”...')
state.infos = [name for name in state.inzf.infolist()]
state.currinfo = 0
statusmsg(f'“{state.path}” contains {len(state.infos)} internal files.')
root.after(state.interval, step_filter_internal_file)
def step_filter_internal_file():
current = state.infos[state.currinfo]
stat = f'Processing “{current.filename}” ({state.currinfo+1}/{len(state.infos)})...'
statusmsg(stat)
# Doing the actual work
regex = None
data = state.inzf.read(current)
if b'sheetProtect' in data:
regex = r'<sheetProtect.*?/>'
statusmsg(f'Worksheet "{current.filename}" is protected.')
elif b'workbookProtect' in data:
regex = r'<workbookProtect.*?/>'
statusmsg('The workbook is protected')
else:
state.outzf.writestr(current, data)
if regex:
text = data.decode('utf-8')
newtext = re.sub(regex, '', text)
if len(newtext) != len(text):
state.outzf.writestr(current, newtext)
state.worksheets_unlocked += 1
statusmsg(f'Removed password from "{current.filename}".')
# Next iteration or next step.
state.currinfo += 1
if state.currinfo >= len(state.infos):
statusmsg('All internal files processed.')
state.currinfo = None
root.after(state.interval, step_close_zipfiles)
else:
root.after(state.interval, step_filter_internal_file)
def step_close_zipfiles():
statusmsg(f'Writing “{state.path}”...')
state.inzf.close()
state.outzf.close()
state.inzf, state.outzf = None, None
root.after(state.interval, step_finished)
def step_finished():
if state.remove:
os.chmod(state.remove, stat.S_IWRITE)
os.remove(state.remove)
state.remove = None
else:
statusmsg('Removing temporary file')
statusmsg(f'Unlocked {state.worksheets_unlocked} worksheets.')
statusmsg('Finished!')
widgets.gobtn['state'] = 'disabled'
widgets.fn['text'] = ''
state.path = ''
# Widget callbacks
def do_file():
"""Callback to open a file"""
if not state.directory:
state.directory = ''
available = [os.environ[k] for k in ('HOME', 'HOMEDRIVE') if k in os.environ]
if available:
state.directory = available[0]
fn = filedialog.askopenfilename(
title='Excel file to open',
parent=root,
defaultextension='.xlsx',
filetypes=(
('excel files', '*.xls*'), ('all files', '*.*')
),
)
if not fn:
return
state.directory = os.path.dirname(fn)
state.worksheets_unlocked = 0
state.workbook_unlocked = False
state.path = fn
widgets.fn['text'] = fn
widgets.gobtn['state'] = 'enabled'
widgets.status.delete(0, tk.END)
def on_backup():
if widgets.backup.get() == 1:
widgets.suffixlabel['state'] = 'enabled'
widgets.suffixentry['state'] = 'enabled'
else:
widgets.suffixlabel['state'] = 'disabled'
widgets.suffixentry['state'] = 'disabled'
def do_start():
root.after(state.interval, step_open_zipfiles)
def do_exit(arg=None):
"""
Callback to handle quitting.
"""
root.destroy()
if __name__ == '__main__':
# Detach from the command line on UNIX systems.
if os.name == 'posix':
if os.fork():
sys.exit() # Create the GUI window.
root = tk.Tk(None)
# Use a dialog window so that it floats even when using a tiling window
# manager.
root.attributes('-type', 'dialog')
# Don't show hidden files in the file dialog
# https://stackoverflow.com/questions/53220711/how-to-avoid-hidden-files-in-file-picker-using-tkinter-filedialog-askopenfilenam
try:
# call a dummy dialog with an impossible option to initialize the file
# dialog without really getting a dialog window; this will throw a
# TclError, so we need a try...except :
try:
root.tk.call('tk_getOpenFile', '-foobarbaz')
except tk.TclError:
pass
# now set the magic variables accordingly
root.tk.call('set', '::tk::dialog::file::showHiddenBtn', '1')
root.tk.call('set', '::tk::dialog::file::showHiddenVar', '0')
except Exception:
pass
# Widgets is a namespace of widgets that needs to be accessed by the callbacks.
# State is a namespace of the global state.
widgets = create_widgets(root)
state = create_state()
root.mainloop()
然后是使用线程的版本:
"""Remove passwords from modern excel 2007+ files (xlsx, xlsm).
This is a multithreaded version of unlock-excel.pyw. All the work that was
there done in steps in the mainloop is now done in a single additional thread.
There is some confusion whether tkinter is thread-safe. That is, if one can
call tkinter functions and methods from any but the main thread. The
documentation for Python 3 says “yes”. Comments in the C source code for
tkinter say “its complicated” depending on how tcl is built. *Many* online
sources say “no”, but that could just be an echo chamber effect.
The author has tested this code on FreeBSD 12.1-STABLE amd64 using CPython
3.7.7 combined with a tcl built with threading enabled. There at least it
seems to work without problems.
"""
from types import SimpleNamespace
import os
import re
import shutil
import stat
import sys
import threading
import zipfile
from tkinter import filedialog
from tkinter import ttk
from tkinter.font import nametofont
import tkinter as tk
__version__ = "2020.04.27"
def create_widgets(root):
"""Create the window and its widgets.
Arguments:
root: the root window.
Returns:
A SimpleNamespace of widgets
"""
# Set the font.
default_font = nametofont("TkDefaultFont")
default_font.configure(size=12)
root.option_add("*Font", default_font)
# General commands and bindings
root.bind_all('q', do_exit)
root.wm_title('Unlock excel files v' + __version__)
root.columnconfigure(3, weight=1)
root.rowconfigure(5, weight=1)
# A SimpleNamespace is used to save widgets that need to be accessed later.
w = SimpleNamespace()
# First row
ttk.Label(root, text='(1)').grid(row=0, column=0, sticky='ew')
fb = ttk.Button(root, text="Select file", command=do_file)
fb.grid(row=0, column=1, columnspan=2, sticky="w")
w.fb = fb
fn = ttk.Label(root)
fn.grid(row=0, column=3, columnspan=2, sticky="ew")
w.fn = fn
# Second row
ttk.Label(root, text='(2)').grid(row=1, column=0, sticky='ew')
backup = tk.IntVar()
backup.set(0)
w.backup = backup
ttk.Checkbutton(root, text='backup', variable=backup,
command=on_backup).grid(row=1, column=1, sticky='ew')
suffixlabel = ttk.Label(root, text='suffix:')
suffixlabel['state'] = 'disabled'
suffixlabel.grid(row=1, column=2, sticky='ew')
w.suffixlabel = suffixlabel
suffix = tk.StringVar()
suffix.set('-orig')
w.suffix = suffix
se = ttk.Entry(root, justify='left', textvariable=suffix)
se.grid(row=1, column=3, columnspan=1, sticky='w')
se['state'] = 'disabled'
w.suffixentry = se
# Third row
ttk.Label(root, text='(3)').grid(row=2, column=0, sticky='ew')
gobtn = ttk.Button(root, text="Go!", command=do_start)
gobtn['state'] = 'disabled'
gobtn.grid(row=2, column=1, sticky='ew')
w.gobtn = gobtn
# Fourth row
ttk.Label(root, text='(4)').grid(row=3, column=0, sticky='ew')
ttk.Label(root, text='Progress:').grid(row=3, column=1, sticky='w')
# Fifth row
sb = tk.Scrollbar(root, orient="vertical")
status = tk.Listbox(root, width=60, yscrollcommand=sb.set)
status.grid(row=4, rowspan=5, column=1, columnspan=3, sticky="nsew")
w.status = status
sb.grid(row=4, rowspan=5, column=5, sticky="ns")
sb.config(command=status.yview)
# Ninth row
ttk.Button(root, text="Quit", command=do_exit).grid(row=9, column=1, sticky='ew')
# Return the widgets that need to be accessed.
return w
def create_state():
"""Create and initialize the global state."""
st = SimpleNamespace()
st.directory = None
return st
def statusmsg(text):
"""Append a message to the status listbox, and make sure it is visible."""
widgets.status.insert(tk.END, text)
widgets.status.see(tk.END)
def process_zipfile_thread():
"""Function to process a zip-file. This is to be run in a thread."""
path = widgets.fn['text']
statusmsg(f'Opening “{path}”...')
first, last = path.rsplit('.', maxsplit=1)
if widgets.backup.get():
backupname = first + widgets.suffix.get() + '.' + last
remove = None
else:
backupname = first + '-orig' + '.' + last
remove = backupname
shutil.move(path, backupname)
with zipfile.ZipFile(backupname, mode="r") as inzf, \
zipfile.ZipFile(
path, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=1
) as outzf:
statusmsg(f'Reading “{path}”...')
infos = [name for name in inzf.infolist()]
statusmsg(f'“{path}” contains {len(infos)} internal files.')
worksheets_unlocked = 0
for idx, current in enumerate(infos, start=1):
smsg = f'Processing “{current.filename}” ({idx}/{len(infos)})...'
statusmsg(smsg)
# Doing the actual work
regex = None
data = inzf.read(current)
if b'sheetProtect' in data:
regex = r'<sheetProtect.*?/>'
statusmsg(f'Worksheet "{current.filename}" is protected.')
elif b'workbookProtect' in data:
regex = r'<workbookProtect.*?/>'
statusmsg('The workbook is protected')
else:
outzf.writestr(current, data)
if regex:
text = data.decode('utf-8')
newtext = re.sub(regex, '', text)
if len(newtext) != len(text):
outzf.writestr(current, newtext)
worksheets_unlocked += 1
statusmsg(f'Removed password from "{current.filename}".')
statusmsg('All internal files processed.')
statusmsg(f'Writing “{path}”...')
if remove:
os.chmod(remove, stat.S_IWRITE)
os.remove(remove)
else:
statusmsg('Removing temporary file')
statusmsg(f'Unlocked {state.worksheets_unlocked} worksheets.')
statusmsg('Finished!')
widgets.gobtn['state'] = 'disabled'
widgets.fn['text'] = ''
# Widget callbacks
def do_file():
"""Callback to open a file"""
if not state.directory:
state.directory = ''
available = [os.environ[k] for k in ('HOME', 'HOMEDRIVE') if k in os.environ]
if available:
state.directory = available[0]
fn = filedialog.askopenfilename(
title='Excel file to open',
parent=root,
defaultextension='.xlsx',
filetypes=(('excel files', '*.xls*'), ('all files', '*.*')),
)
if not fn:
return
state.directory = os.path.dirname(fn)
state.worksheets_unlocked = 0
state.workbook_unlocked = False
widgets.fn['text'] = fn
widgets.gobtn['state'] = 'enabled'
widgets.status.delete(0, tk.END)
def on_backup():
if widgets.backup.get() == 1:
widgets.suffixlabel['state'] = 'enabled'
widgets.suffixentry['state'] = 'enabled'
else:
widgets.suffixlabel['state'] = 'disabled'
widgets.suffixentry['state'] = 'disabled'
def do_start():
worker = threading.Thread(target=process_zipfile_thread)
worker.start()
def do_exit(arg=None):
"""
Callback to handle quitting.
"""
root.destroy()
if __name__ == '__main__':
# Detach from the command line on UNIX systems.
if os.name == 'posix':
if os.fork():
sys.exit()
# Create the GUI window.
root = tk.Tk(None)
# Use a dialog window so that it floats even when using a tiling window manager.
if os.name == 'posix':
root.attributes('-type', 'dialog')
# Don't show hidden files in the file dialog
# https://stackoverflow.com/questions/53220711/how-to-avoid-hidden-files-in-file-picker-using-tkinter-filedialog-askopenfilenam
try:
# call a dummy dialog with an impossible option to initialize the file
# dialog without really getting a dialog window; this will throw a
# TclError, so we need a try...except :
try:
root.tk.call('tk_getOpenFile', '-foobarbaz')
except tk.TclError:
pass
# now set the magic variables accordingly
root.tk.call('set', '::tk::dialog::file::showHiddenBtn', '1')
root.tk.call('set', '::tk::dialog::file::showHiddenVar', '0')
except Exception:
pass
# Widgets is a namespace of widgets that needs to be accessed by the callbacks.
# State is a namespace of the global state.
widgets = create_widgets(root)
state = create_state()
root.mainloop()